792 lines · 33.8 KB · Zig
RawDownload
const std = @import("std");const io_mod = @import("../core/shared/io.zig");const host = @import("../core/hosts/host.zig");const display_width = @import("../core/shared/display_width.zig");const types = @import("../core/shared/types.zig");const image_attachments = @import("../core/images/image_attachments.zig");const assistant_presentation = @import("../core/agent/assistant_presentation.zig");const main = @import("../main.zig");const theme_detection = @import("terminal/theme_detection.zig");const theme_protocol = @import("terminal/theme_protocol.zig");const visual_layout = @import("input/visual_layout.zig"); pub const input_prefix = "❯ ";pub const TerminalRgb = user_message_card.Rgb;pub const reset_style = "\x1b[0m";pub const bold_style = "\x1b[1m";pub const app_name = "fx";pub const right_tag = "/fx";pub const ask_activity_label = "⏺ Asking"; const user_message_card = @import("assistant/user_message_card.zig"); pub const welcome_message_reserved_rows: u16 = 11; pub var is_light: bool = false;pub var input_bar_style: []const u8 = "";pub var divider_style: []const u8 = "\x1b[38;5;240m";pub var hint_style: []const u8 = "\x1b[38;5;255m";pub var statusline_style: []const u8 = "\x1b[38;5;245m";pub var tag_style: []const u8 = "\x1b[1;38;5;255m";pub var subtitle_style: []const u8 = "\x1b[1;38;5;255m";pub var system_notice_label_style: []const u8 = "\x1b[1;38;5;252m";pub var system_notice_text_style: []const u8 = "\x1b[38;5;250m";pub var dim_style: []const u8 = "\x1b[38;5;245m";pub var warning_style: []const u8 = "\x1b[38;5;252m";pub var green_style: []const u8 = "\x1b[38;5;252m";pub var red_style: []const u8 = "\x1b[38;5;252m";pub var diff_added_style: []const u8 = "\x1b[38;5;252m";pub var diff_removed_style: []const u8 = "\x1b[38;5;252m";// The line number and +/- sign carry the only color in an otherwise// monochrome diff: green for additions (#30A46C), red for deletions// (#E5484D). The line text stays neutral. Truecolor when the terminal// supports it, 256-color fallback otherwise.const diff_added_marker_truecolor = "\x1b[38;2;48;164;108m";const diff_removed_marker_truecolor = "\x1b[38;2;229;72;77m";const diff_added_marker_fallback = "\x1b[38;5;71m";const diff_removed_marker_fallback = "\x1b[38;5;167m";pub var diff_added_marker_style: []const u8 = diff_added_marker_fallback;pub var diff_removed_marker_style: []const u8 = diff_removed_marker_fallback;pub var approval_button_active_style: []const u8 = "\x1b[48;5;255m\x1b[38;5;235m\x1b[1m";pub var approval_button_inactive_style: []const u8 = "\x1b[48;5;239m\x1b[38;5;255m";pub var selected_completion_style: []const u8 = "\x1b[1;38;5;255m";// Statusbar permissions "auto": a step brighter than the statusline gray.pub var permission_auto_style: []const u8 = "\x1b[38;5;252m";var active_terminal_background: ?TerminalRgb = null; var truecolor_enabled: bool = true; pub fn setTruecolorSupport(enabled: bool) void {    truecolor_enabled = enabled;} pub fn initTheme(light: bool, terminal_bg: ?TerminalRgb) void {    is_light = light;    active_terminal_background = terminal_bg;    assistant_presentation.setInlineCodeTheme(light);    if (light) {        input_bar_style = "";        divider_style = "\x1b[38;5;250m";        hint_style = "\x1b[38;5;235m";        statusline_style = "\x1b[38;5;241m";        tag_style = "\x1b[1;38;5;235m";        subtitle_style = "\x1b[1;38;5;235m";        system_notice_label_style = "\x1b[1;38;5;238m";        system_notice_text_style = "\x1b[38;5;241m";        dim_style = "\x1b[38;5;247m";        warning_style = "\x1b[38;5;238m";        green_style = "\x1b[38;5;238m";        red_style = "\x1b[38;5;238m";        diff_added_style = "\x1b[38;5;238m";        diff_removed_style = "\x1b[38;5;238m";        approval_button_active_style = "\x1b[48;5;236m\x1b[38;5;255m\x1b[1m";        approval_button_inactive_style = "\x1b[48;5;251m\x1b[38;5;237m";        selected_completion_style = "\x1b[1;38;5;235m";        permission_auto_style = "\x1b[38;5;238m";    } else {        input_bar_style = "";        divider_style = "\x1b[38;5;240m";        hint_style = "\x1b[38;5;255m";        statusline_style = "\x1b[38;5;245m";        tag_style = "\x1b[1;38;5;255m";        subtitle_style = "\x1b[1;38;5;255m";        system_notice_label_style = "\x1b[1;38;5;252m";        system_notice_text_style = "\x1b[38;5;250m";        dim_style = "\x1b[38;5;245m";        warning_style = "\x1b[38;5;252m";        green_style = "\x1b[38;5;252m";        red_style = "\x1b[38;5;252m";        diff_added_style = "\x1b[38;5;252m";        diff_removed_style = "\x1b[38;5;252m";        approval_button_active_style = "\x1b[48;5;255m\x1b[38;5;235m\x1b[1m";        approval_button_inactive_style = "\x1b[48;5;239m\x1b[38;5;255m";        selected_completion_style = "\x1b[1;38;5;255m";        permission_auto_style = "\x1b[38;5;252m";    }     // The diff marker green/red reads the same on light and dark, so it is set    // once here rather than per-theme.    if (truecolor_enabled) {        diff_added_marker_style = diff_added_marker_truecolor;        diff_removed_marker_style = diff_removed_marker_truecolor;    } else {        diff_added_marker_style = diff_added_marker_fallback;        diff_removed_marker_style = diff_removed_marker_fallback;    }     // Delegate bar shade computation to the card module — it owns the logic    // that derives a subtle but visible shade from the terminal's actual bg.    user_message_card.setTruecolor(truecolor_enabled);    user_message_card.setStyle(light, terminal_bg);    input_bar_style = user_message_card.user_message_style;} pub fn themeNeedsUpdate(light: bool, terminal_bg: ?TerminalRgb) bool {    if (light != is_light) return true;    const background = terminal_bg orelse return false;    const current = active_terminal_background orelse return true;    return current.r != background.r or current.g != background.g or current.b != background.b;} // Explicit theme overrides skip OSC 11, leaving `rgb` null for fallback shading.pub const ThemeDetection = theme_detection.Detection;pub const TerminalBackground = theme_protocol.Background;pub const explicitThemeOverride = theme_detection.explicitThemeOverride;pub const detectTheme = theme_detection.detectTheme;pub const parseOsc11Response = theme_protocol.parseOsc11Response;pub const truecolorSupportedForValues = theme_protocol.truecolorSupportedForValues; pub const InputLineView = struct {    line: []const u8,    cursor_col: u16,}; pub const MultilineInfo = struct {    total_lines: u16,    cursor_line: u16,}; pub fn countMultilineInfo(input: []const u8, cursor: usize, width: u16) MultilineInfo {    const summary = visual_layout.summarize(.{ .input = input, .cursor = cursor, .terminal_cols = width }, null);    return .{        .total_lines = @intCast(@min(summary.total_rows, std.math.maxInt(u16))),        .cursor_line = @intCast(@min(summary.cursor.row_index, std.math.maxInt(u16))),    };} pub fn buildInputLineForRow(input: []const u8, cursor: usize, line_index: usize, total_lines: usize, width: u16, out: []u8) InputLineView {    if (width == 0 or out.len == 0) return .{ .line = "", .cursor_col = 1 };     const source = visual_layout.Source{ .input = input, .cursor = cursor, .terminal_cols = width };    const summary = visual_layout.summarize(source, null);    const visible_rows = @max(total_lines, 1);    const window = visual_layout.visibleWindow(summary.cursor.row_index, summary.total_rows, visible_rows);    const actual_row = window.first_row + line_index;    const line = copyVisualRowToBuffer(source, actual_row, out);    return .{        .line = line,        .cursor_col = if (summary.cursor.row_index == actual_row) visual_layout.terminalColumn(summary.cursor, width) else 0,    };} pub fn welcomeMessage(alloc: std.mem.Allocator) ![]u8 {    return std.fmt.allocPrint(        alloc,        "{s}𝒇x{s}{s} v{s} · Run /help for commands" ++ reset_style ++ "\n\n",        .{ subtitle_style, reset_style, dim_style, main.version },    );} pub const StatuslineItems = struct {    sandbox_label: ?[]const u8 = null,    context_used: u64 = 0,    context_total: ?u32 = null,    session_title: ?[]const u8 = null,}; /// Cell budget for the session title segment. The title is capped at 8 words/// upstream, so this only bounds pathological single-word titles.const max_session_title_cells: usize = 32; fn compactModelLabel(model: []const u8, out: []u8) []const u8 {    var start: usize = 0;    for (model, 0..) |byte, i| {        if (byte == '/') start = i + 1;    }    const bare = model[start..];     const claude_prefix = "claude-";    if (!std.mem.startsWith(u8, bare, claude_prefix)) return bare;    const claude_name = bare[claude_prefix.len..];     inline for (&.{        .{ "opus-", "opus " },        .{ "sonnet-", "sonnet " },        .{ "haiku-", "haiku " },    }) |mapping| {        const prefix = mapping[0];        const label = mapping[1];        if (std.mem.startsWith(u8, claude_name, prefix)) {            return std.fmt.bufPrint(out, "{s}{s}", .{ label, claude_name[prefix.len..] }) catch bare;        }    }     return claude_name;} fn permissionModeStatusLabel(mode: types.PermissionMode, out: []u8) []const u8 {    return switch (mode) {        .ask => "ask",        .auto => std.fmt.bufPrint(out, "{s}auto{s}", .{ permission_auto_style, statusline_style }) catch "auto",        .yolo => std.fmt.bufPrint(out, "{s}YOLO{s}", .{ permission_auto_style, statusline_style }) catch "YOLO",    };} fn appendStatusSegment(out: []u8, end: *usize, segment: []const u8) void {    if (segment.len == 0) return;    const sep = " · ";    const sep_len = if (end.* > 0) sep.len else 0;    if (end.* + sep_len + segment.len > out.len) return;     if (sep_len > 0) {        @memcpy(out[end.* .. end.* + sep.len], sep);        end.* += sep.len;    }    @memcpy(out[end.* .. end.* + segment.len], segment);    end.* += segment.len;} fn leadingPermissionModeFits(limit: usize, permission_label: []const u8, model_label: []const u8) bool {    if (limit == 0) return false;    return display_width.visibleWidthIgnoringAnsi(permission_label) + display_width.visibleWidth(" · ") + display_width.visibleWidth(model_label) <= limit;} pub fn buildHintLine(    stream_active: bool,    awaiting_permission: bool,    has_api_key: bool,    model: []const u8,    permission_mode: types.PermissionMode,    queued_count: usize,    active_label: ?[]const u8,    fast_mode: bool,    model_supports_fast: bool,    effort: types.ReasoningEffort,    model_supports_effort: bool,    statusline: StatuslineItems,    width: u16,    out: []u8,) []const u8 {    _ = active_label;    _ = stream_active;     var model_buf: [96]u8 = undefined;    const model_label = compactModelLabel(model, &model_buf);    var permission_buf: [64]u8 = undefined;    const permission_label = permissionModeStatusLabel(permission_mode, &permission_buf);     var end: usize = 0;    if (!awaiting_permission and !has_api_key) {        appendStatusSegment(out, &end, "run /login");    }    if (!awaiting_permission and queued_count > 0) {        var queued_buf: [32]u8 = undefined;        appendStatusSegment(out, &end, std.fmt.bufPrint(&queued_buf, "queued {d}", .{queued_count}) catch "");    }    const status_limit = @min(@as(usize, width), out.len);    if (leadingPermissionModeFits(status_limit, permission_label, model_label)) {        appendStatusSegment(out, &end, permission_label);    }    appendStatusSegment(out, &end, model_label);    if (model_supports_effort and !effort.isDefault()) {        appendStatusSegment(out, &end, effort.displayLabel());    }    if (model_supports_fast and fast_mode) {        appendStatusSegment(out, &end, "⚡︎");    }     if (statusline.session_title) |title| {        appendStatusSegment(out, &end, display_width.prefixByWidth(title, max_session_title_cells));    }     if (statusline.sandbox_label) |sb_label| {        var sandbox_buf: [64]u8 = undefined;        appendStatusSegment(out, &end, std.fmt.bufPrint(&sandbox_buf, "sandbox:{s}", .{sb_label}) catch "");    }     if (statusline.context_used > 0) {        if (statusline.context_total) |total| {            const used_k = statusline.context_used / 1000;            const total_k: u64 = @as(u64, total) / 1000;            const pct = if (total > 0) (statusline.context_used * 100) / @as(u64, total) else 0;            var ctx_buf: [48]u8 = undefined;            appendStatusSegment(out, &end, std.fmt.bufPrint(&ctx_buf, "Context: {d}k/{d}k {d}%", .{ used_k, total_k, pct }) catch "");        } else {            const used_k = statusline.context_used / 1000;            var ctx_buf: [32]u8 = undefined;            appendStatusSegment(out, &end, std.fmt.bufPrint(&ctx_buf, "Context: {d}k", .{used_k}) catch "");        }    }     const width_usize: usize = width;    if (width_usize == 0) return "";    return display_width.prefixByWidthIgnoringAnsi(out[0..end], width_usize);} pub fn buildInputLine(input: []const u8, cursor: usize, width: u16, out: []u8) InputLineView {    const ml = countMultilineInfo(input, cursor, width);    return buildInputLineForRow(input, cursor, ml.cursor_line, ml.total_lines, width, out);} pub fn inputColumnForIndex(input: []const u8, cursor: usize, target: usize, width: u16) u16 {    const summary = visual_layout.summarize(.{ .input = input, .cursor = cursor, .terminal_cols = width }, target);    return visual_layout.projectedAnchorColumn(summary, width);} fn copyVisualRowToBuffer(source: visual_layout.Source, target_row: usize, out: []u8) []const u8 {    if (source.terminal_cols == 0 or out.len == 0) return "";     var len: usize = 0;    var row_started = false;    var remaining_cells: usize = 0;    var omitted_positive_unit = false;    var it = visual_layout.iterator(source);    while (it.next()) |event| switch (event) {        .unit => |unit| {            if (unit.row_index < target_row) continue;            if (unit.row_index > target_row) break;            startVisualRow(source.terminal_cols, target_row, out, &len, &row_started, &remaining_cells);            if (remaining_cells == 0) continue;            switch (unit.kind) {                .text => {                    if (unit.cell_width == 0) {                        if (!omitted_positive_unit) appendBytesToBuffer(out, &len, source.input[unit.raw_start..unit.raw_end]);                    } else if (unit.cell_width <= remaining_cells) {                        appendBytesToBuffer(out, &len, source.input[unit.raw_start..unit.raw_end]);                        remaining_cells -= unit.cell_width;                        omitted_positive_unit = false;                    } else {                        omitted_positive_unit = true;                    }                },                .paste_placeholder => {                    const visible = display_width.prefixByWidth(                        source.input[unit.raw_start..unit.raw_end],                        remaining_cells,                    );                    appendBytesToBuffer(out, &len, visible);                    const emitted_width = display_width.visibleWidth(visible);                    remaining_cells -= emitted_width;                    omitted_positive_unit = emitted_width < unit.cell_width;                },                .tab => {                    if (unit.cell_width > 0 and unit.cell_width <= remaining_cells) {                        appendSpacesToBuffer(out, &len, unit.cell_width);                        remaining_cells -= unit.cell_width;                        omitted_positive_unit = false;                    } else if (unit.cell_width > 0) {                        omitted_positive_unit = true;                    }                },                .skill_token => |token_index| {                    const token = source.skill_tokens[token_index];                    if (unit.cell_width <= remaining_cells) {                        appendBytesToBuffer(out, &len, token.name);                        if (visual_layout.skillTokenSourceLabel(token)) |source_label| {                            appendBytesToBuffer(out, &len, visual_layout.skill_source_separator);                            appendBytesToBuffer(out, &len, source_label);                        }                        remaining_cells -= unit.cell_width;                        omitted_positive_unit = false;                    } else {                        omitted_positive_unit = true;                    }                },                .image_badge => {},            }        },        .row_end => |row| {            if (row.index < target_row) continue;            if (row.index == target_row) startVisualRow(source.terminal_cols, target_row, out, &len, &row_started, &remaining_cells);            break;        },    };    return out[0..len];} fn startVisualRow(width: u16, row_index: usize, out: []u8, len: *usize, started: *bool, remaining_cells: *usize) void {    if (started.*) return;    started.* = true;    const prefix = visual_layout.inputPrefix(row_index);    const clipped = display_width.prefixByWidth(prefix.bytes, width);    appendBytesToBuffer(out, len, clipped);    const width_usize: usize = width;    remaining_cells.* = if (width_usize > prefix.cell_width) width_usize - prefix.cell_width else 0;} fn appendBytesToBuffer(out: []u8, len: *usize, bytes: []const u8) void {    if (bytes.len > out.len - len.*) return;    @memcpy(out[len.* .. len.* + bytes.len], bytes);    len.* += bytes.len;} fn appendSpacesToBuffer(out: []u8, len: *usize, count: usize) void {    var i: usize = 0;    while (i < count and len.* < out.len) : (i += 1) {        out[len.*] = ' ';        len.* += 1;    }} pub fn isPrintableAscii(byte: u8) bool {    return byte >= 32 and byte <= 126;} test "input line wraps to the cursor row" {    var buf: [64]u8 = undefined;    const view = buildInputLine("abcdefghijklmnopqrstuvwxyz", 26, 16, &buf);    try std.testing.expectEqualStrings("  opqrstuvwxyz", view.line);    try std.testing.expectEqual(@as(u16, 15), view.cursor_col);} test "input line wraps without cutting emoji bytes" {    var buf: [64]u8 = undefined;    const view = buildInputLine("a😀b", "a😀b".len, 5, &buf);    try std.testing.expectEqualStrings("  b", view.line);    try std.testing.expectEqual(@as(u16, 4), view.cursor_col);} test "wrapped input row builder exposes all visual rows" {    var buf: [64]u8 = undefined;    const input = "abcdefgh";    const ml = countMultilineInfo(input, input.len, 6);    try std.testing.expectEqual(@as(u16, 2), ml.total_lines);    try std.testing.expectEqual(@as(u16, 1), ml.cursor_line);     const first = buildInputLineForRow(input, input.len, 0, ml.total_lines, 6, &buf);    try std.testing.expectEqualStrings("❯ abcd", first.line);    try std.testing.expectEqual(@as(u16, 0), first.cursor_col);     const second = buildInputLineForRow(input, input.len, 1, ml.total_lines, 6, &buf);    try std.testing.expectEqualStrings("  efgh", second.line);    try std.testing.expectEqual(@as(u16, 6), second.cursor_col);} test "hard newline keeps cursor at end of previous row" {    var buf: [64]u8 = undefined;    const input = "abc\ndef";    const view = buildInputLine(input, 3, 20, &buf);    try std.testing.expectEqualStrings("❯ abc", view.line);    try std.testing.expectEqual(@as(u16, 6), view.cursor_col);} test "inputColumnForIndex aligns active token under input" {    const input = "/model openai/gpt-5 high";    const col = inputColumnForIndex(input, input.len, "/model openai/gpt-5 ".len, 80);    try std.testing.expectEqual(@as(u16, 23), col);} test "render geometry wrappers match visual layout projections" {    const input = "abc\ndefgh";    const source = visual_layout.Source{ .input = input, .cursor = input.len, .terminal_cols = 6 };    const summary = visual_layout.summarize(source, "abc\n".len + 1);    const ml = countMultilineInfo(input, input.len, 6);    try std.testing.expectEqual(@as(u16, @intCast(summary.total_rows)), ml.total_lines);    try std.testing.expectEqual(@as(u16, @intCast(summary.cursor.row_index)), ml.cursor_line);    try std.testing.expectEqual(visual_layout.projectedAnchorColumn(summary, 6), inputColumnForIndex(input, input.len, "abc\n".len + 1, 6));     try std.testing.expectEqual(@as(u16, 3), visual_layout.terminalColumn(.{ .raw_offset = 0, .row_index = 0, .content_column = 0 }, 80));    try std.testing.expectEqual(@as(u16, 3), visual_layout.terminalColumn(.{ .raw_offset = 0, .row_index = 1, .content_column = 0 }, 80));    try std.testing.expectEqual(@as(u16, 6), visual_layout.terminalColumn(.{ .raw_offset = 3, .row_index = 0, .content_column = 3 }, 80));} test "render row window stays cursor-containing for direct and restored input" {    var buf: [128]u8 = undefined;    inline for (.{ "x" ** 4096, "y" ** 5000 }) |input| {        const summary = visual_layout.summarize(.{ .input = input, .cursor = input.len, .terminal_cols = 80 }, null);        const window = visual_layout.visibleWindow(summary.cursor.row_index, summary.total_rows, 4);        try std.testing.expect(window.first_row <= summary.cursor.row_index);        try std.testing.expect(summary.cursor.row_index < window.first_row + window.row_count);         const view = buildInputLineForRow(input, input.len, 3, 4, 80, &buf);        try std.testing.expect(std.mem.startsWith(u8, view.line, "  "));        try std.testing.expect(view.cursor_col > 0);    }} test "render soft-wrap boundary and earlier anchors use cursor row columns" {    var buf: [64]u8 = undefined;    const input = "abcd";    const view = buildInputLine(input, 3, 5, &buf);

Lines 1–500 of 792.