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. Load more
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);