Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165const std = @import("std");const build_options = @import("build_options");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 text_utils = @import("../core/shared/text_utils.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 shared_theme = @import("../core/shared/theme.zig");const visual_layout = @import("input/visual_layout.zig");const update_target = @import("../core/upgrade/update_target.zig");
pub const TerminalRgb = user_message_card.Rgb;pub const reset_style = "\x1b[0m";pub const bold_style = "\x1b[1m";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 divider_style: []const u8 = shared_theme.fx_dark.divider_style;pub var hint_style: []const u8 = shared_theme.fx_dark.hint_style;pub var statusline_style: []const u8 = shared_theme.fx_dark.statusline_style;pub var tag_style: []const u8 = shared_theme.fx_dark.tag_style;pub var subtitle_style: []const u8 = shared_theme.fx_dark.subtitle_style;pub var system_notice_label_style: []const u8 = shared_theme.fx_dark.system_notice_label_style;pub var system_notice_text_style: []const u8 = shared_theme.fx_dark.system_notice_text_style;pub var dim_style: []const u8 = shared_theme.fx_dark.dim_style;pub var warning_style: []const u8 = shared_theme.fx_dark.warning_style;pub var green_style: []const u8 = shared_theme.fx_dark.green_style;pub var red_style: []const u8 = shared_theme.fx_dark.red_style;pub var diff_added_style: []const u8 = shared_theme.fx_dark.diff_added_style;pub var diff_removed_style: []const u8 = shared_theme.fx_dark.diff_removed_style;pub var diff_added_marker_style: []const u8 = shared_theme.fx_dark.diff_added_marker_fallback;pub var diff_removed_marker_style: []const u8 = shared_theme.fx_dark.diff_removed_marker_fallback;pub var approval_button_active_style: []const u8 = shared_theme.fx_dark.approval_button_active_style;pub var approval_button_inactive_style: []const u8 = shared_theme.fx_dark.approval_button_inactive_style;pub var selected_completion_style: []const u8 = shared_theme.fx_dark.selected_completion_style;// Statusbar permissions "auto": a step brighter than the statusline gray.pub var permission_auto_style: []const u8 = shared_theme.fx_dark.permission_auto_style;var active_terminal_background: ?TerminalRgb = null;
var truecolor_enabled: bool = true;
pub fn setTruecolorSupport(enabled: bool) void { truecolor_enabled = enabled;}
// A configured light|dark pin (FX_THEME or the settings "theme" key) locks the// variant. Custom themes keep the live monitor so terminal mode flips// re-resolve the theme pair without a restart.pub fn themeInputLocked() bool { return explicitThemeOverride() != null or shared_theme.variantPinned();}
pub fn truecolorIsEnabled() bool { return truecolor_enabled;}
pub fn initTheme(light: bool, terminal_bg: ?TerminalRgb) void { applyTheme(shared_theme.builtin(light), terminal_bg);}
pub fn applyTheme(theme: shared_theme.Theme, terminal_bg: ?TerminalRgb) void { shared_theme.activate(theme); is_light = theme.light; active_terminal_background = terminal_bg; assistant_presentation.applyTheme(theme); divider_style = theme.divider_style; hint_style = theme.hint_style; statusline_style = theme.statusline_style; tag_style = theme.tag_style; subtitle_style = theme.subtitle_style; system_notice_label_style = theme.system_notice_label_style; system_notice_text_style = theme.system_notice_text_style; dim_style = theme.dim_style; warning_style = theme.warning_style; green_style = theme.green_style; red_style = theme.red_style; diff_added_style = theme.diff_added_style; diff_removed_style = theme.diff_removed_style; approval_button_active_style = theme.approval_button_active_style; approval_button_inactive_style = theme.approval_button_inactive_style; selected_completion_style = theme.selected_completion_style; permission_auto_style = theme.permission_auto_style;
// The diff marker green/red reads the same on light and dark, so both // capability variants ship in every theme; the terminal picks which applies. if (truecolor_enabled) { diff_added_marker_style = theme.diff_added_marker_truecolor; diff_removed_marker_style = theme.diff_removed_marker_truecolor; } else { diff_added_marker_style = theme.diff_added_marker_fallback; diff_removed_marker_style = theme.diff_removed_marker_fallback; }
user_message_card.applyTheme(theme, terminal_bg);}
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 explicitThemeOverride = theme_detection.explicitThemeOverride;pub const explicitThemeName = theme_detection.explicitThemeName;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, };}
const build_channel = update_target.Channel.parse(build_options.update_channel) orelse .stable;const welcome_build_label_bytes: usize = 96;const dev_revision_bytes: usize = 7;
/// Dev builds ship on every merged PR, so the version alone cannot identify the/// binary: the header carries the commit and a brighter `[dev]` tag.fn writeBuildLabel( out: []u8, channel: update_target.Channel, version_text: []const u8, revision: []const u8,) ![]const u8 { if (channel != .dev) return std.fmt.bufPrint(out, "v{s}", .{version_text}); if (revision.len < dev_revision_bytes or std.mem.eql(u8, revision, "unknown")) { return std.fmt.bufPrint(out, "v{s} {s}[dev]{s}", .{ version_text, hint_style, dim_style }); } return std.fmt.bufPrint(out, "v{s}-{s} {s}[dev]{s}", .{ version_text, revision[0..dev_revision_bytes], hint_style, dim_style, });}
pub fn welcomeMessage(alloc: std.mem.Allocator) ![]u8 { var label_buf: [welcome_build_label_bytes]u8 = undefined; const build_label = try writeBuildLabel( &label_buf, build_channel, main.version, build_options.git_commit, ); return std.fmt.allocPrint( alloc, "{s}𝒇x{s}{s} {s} · Run /help for commands" ++ reset_style ++ "\n\n", .{ subtitle_style, reset_style, dim_style, build_label }, );}
pub const StatuslineItems = struct { workspace_label: []const u8 = "", git_branch: ?[]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}full access{s}", .{ permission_auto_style, statusline_style }) catch "full access", };}
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;}
const statusline_separator = " · ";
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(statusline_separator) + display_width.visibleWidth(model_label) <= limit;}
const ClippedSegment = struct { bytes: []const u8, marker_before: bool = false, marker_after: bool = false,};
fn clippedWorkspaceSuffix(encoded: []const u8, max_width: usize) ClippedSegment { if (display_width.visibleWidth(encoded) <= max_width) return .{ .bytes = encoded }; if (max_width <= 1) return .{ .bytes = "", .marker_before = max_width == 1 }; return .{ .bytes = text_utils.suffixTerminalSafeByWidth(encoded, max_width - 1), .marker_before = true, };}
fn clippedBranchPrefix(encoded: []const u8, max_width: usize) ClippedSegment { if (display_width.visibleWidth(encoded) <= max_width) return .{ .bytes = encoded }; if (max_width <= 1) return .{ .bytes = "", .marker_after = max_width == 1 }; return .{ .bytes = text_utils.prefixTerminalSafeByWidth(encoded, max_width - 1), .marker_after = true, };}
fn appendIdentityBytes(out: []u8, end: *usize, bytes: []const u8) bool { if (end.* + bytes.len > out.len) return false; @memcpy(out[end.* .. end.* + bytes.len], bytes); end.* += bytes.len; return true;}
fn appendClippedIdentityPart( out: []u8, end: *usize, clipped: ClippedSegment,) bool { if (clipped.marker_before and !appendIdentityBytes(out, end, "…")) return false; if (!appendIdentityBytes(out, end, clipped.bytes)) return false; if (clipped.marker_after and !appendIdentityBytes(out, end, "…")) return false; return true;}
fn composeWorkspaceIdentity( statusline: StatuslineItems, max_width: usize, out: []u8,) ?[]const u8 { if (statusline.workspace_label.len == 0 or max_width == 0) return null;
var width_budget = @min(max_width, out.len); while (width_budget > 0) : (width_budget -= 1) { var end: usize = 0; if (statusline.git_branch) |branch| { if (branch.len > 0) { // Below seven cells, showing fragments of both values is less // useful than retaining the working-directory tail alone. if (width_budget >= 7) { const branch_width = display_width.visibleWidth(branch); const max_branch_width = width_budget - 4; const branch_budget = @min( branch_width, @min(max_branch_width, @max(@as(usize, 4), width_budget / 2)), ); const path_budget = width_budget - 3 - branch_budget; const path = clippedWorkspaceSuffix(statusline.workspace_label, path_budget); const branch_label = clippedBranchPrefix(branch, branch_budget); if (appendClippedIdentityPart(out, &end, path) and appendIdentityBytes(out, &end, " (") and appendClippedIdentityPart(out, &end, branch_label) and appendIdentityBytes(out, &end, ")")) { return out[0..end]; } continue; } } }
const path = clippedWorkspaceSuffix(statusline.workspace_label, width_budget); if (appendClippedIdentityPart(out, &end, path)) return out[0..end]; } return null;}
fn appendWorkspaceIdentity( out: []u8, end: *usize, status_limit: usize, statusline: StatuslineItems,) void { if (statusline.workspace_label.len == 0) return; const used_width = display_width.visibleWidthIgnoringAnsi(out[0..end.*]); const separator_width = if (end.* > 0) display_width.visibleWidth(statusline_separator) else 0; if (used_width + separator_width >= status_limit) return;
const separator_bytes = if (end.* > 0) statusline_separator.len else 0; if (end.* + separator_bytes >= out.len) return; const available_width = status_limit - used_width - separator_width; const available_bytes = out.len - end.* - separator_bytes; var identity_buf: [512]u8 = undefined; const identity = composeWorkspaceIdentity( statusline, available_width, identity_buf[0..@min(identity_buf.len, available_bytes)], ) orelse return; appendStatusSegment(out, end, identity);}
fn appendSessionStatusSegments( out: []u8, end: *usize, status_limit: usize, model: []const u8, effort: types.ReasoningEffort, model_supports_effort: bool, fast_indicator_active: bool, statusline: StatuslineItems,) void { var model_buf: [96]u8 = undefined; appendStatusSegment(out, end, compactModelLabel(model, &model_buf)); if (model_supports_effort and !effort.isDefault()) { appendStatusSegment(out, end, effort.displayLabel()); } if (fast_indicator_active) { appendStatusSegment(out, end, "⚡︎"); } if (statusline.session_title) |title| { appendStatusSegment(out, end, display_width.prefixByWidth(title, max_session_title_cells)); } 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, "{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, "{d}k", .{used_k}) catch ""); } } appendWorkspaceIdentity(out, end, status_limit, statusline);}
pub const subagent_status_width: u16 = 200;
pub fn buildSessionStatusLine( model: []const u8, effort: types.ReasoningEffort, model_supports_effort: bool, statusline: StatuslineItems, width: u16, out: []u8,) []const u8 { var end: usize = 0; const status_limit = @min(@as(usize, width), out.len); appendSessionStatusSegments(out, &end, status_limit, model, effort, model_supports_effort, false, statusline); if (width == 0) return ""; return display_width.prefixByWidthIgnoringAnsi(out[0..end], width);}
pub fn buildHintLine( awaiting_permission: bool, has_api_key: bool, model: []const u8, permission_mode: types.PermissionMode, fast_indicator_active: bool, effort: types.ReasoningEffort, model_supports_effort: bool, statusline: StatuslineItems, width: u16, out: []u8,) []const u8 { 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"); } const status_limit = @min(@as(usize, width), out.len); if (leadingPermissionModeFits(status_limit, permission_label, model_label)) { appendStatusSegment(out, &end, permission_label); } appendSessionStatusSegments(out, &end, status_limit, model, effort, model_supports_effort, fast_indicator_active, statusline);
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); 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; }}
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); try std.testing.expectEqualStrings(" d", view.line); try std.testing.expectEqual(@as(u16, 3), view.cursor_col);
const earlier_anchor_col = inputColumnForIndex("abcdef", 5, 1, 5); try std.testing.expectEqual(@as(u16, 3), earlier_anchor_col);}
test "render tabs use visual layout absolute tab stops" { const input = "\tX"; const summary = visual_layout.summarize(.{ .input = input, .cursor = 1, .terminal_cols = 10 }, 1); try std.testing.expectEqual(@as(u16, 9), visual_layout.projectedAnchorColumn(summary, 10)); try std.testing.expectEqual(@as(u16, 9), inputColumnForIndex(input, 1, 1, 10));
const wrapped = "aaaaaa\tXY"; const wrapped_summary = visual_layout.summarize(.{ .input = wrapped, .cursor = wrapped.len, .terminal_cols = 10 }, wrapped.len - 1); try std.testing.expectEqual(visual_layout.projectedAnchorColumn(wrapped_summary, 10), inputColumnForIndex(wrapped, wrapped.len, wrapped.len - 1, 10));}
test "render width zero emits no row bytes and first cursor column" { var buf: [8]u8 = undefined; const view = buildInputLine("abc", 3, 0, &buf); try std.testing.expectEqualStrings("", view.line); try std.testing.expectEqual(@as(u16, 1), view.cursor_col);}
/// Writes the title to the same file the transcript renders to, so a host/// that redirects its output keeps the escape sequence out of the real/// stdout. `out` must outlive every call.pub fn terminalTitleFor(out: *const std.Io.File) host.TerminalTitle { return .{ .context = @constCast(out), .set_fn = setTerminalTitleLabel, .clear_fn = clearTerminalTitleProvider, };}
fn titleOutput(raw: ?*anyopaque) std.Io.File { const out: *const std.Io.File = @ptrCast(@alignCast(raw.?)); return out.*;}
const terminal_title_osc_prefix = "\x1b]2;";const terminal_title_max_content_bytes: usize = 128;const terminal_title_max_label_bytes = terminal_title_max_content_bytes;
fn sanitizedTerminalTitleLabel(raw: []const u8, buffer: *[terminal_title_max_label_bytes]u8) []const u8 { const marker = "..."; var source_index: usize = 0; var written: usize = 0; while (source_index < raw.len) { const sequence_len: usize = std.unicode.utf8ByteSequenceLength(raw[source_index]) catch { source_index += 1; continue; }; if (raw.len - source_index < sequence_len) break; const sequence = raw[source_index .. source_index + sequence_len]; const codepoint = std.unicode.utf8Decode(sequence) catch { source_index += 1; continue; }; source_index += sequence_len; if (codepoint < 0x20 or (codepoint >= 0x7f and codepoint <= 0x9f)) continue; if (written + sequence_len > buffer.len) { written = text_utils.utf8BackwardBoundary(buffer[0..written], buffer.len - marker.len); @memcpy(buffer[written .. written + marker.len], marker); written += marker.len; break; } @memcpy(buffer[written .. written + sequence_len], sequence); written += sequence_len; } return buffer[0..written];}
fn setTerminalTitleLabel(raw: ?*anyopaque, label: []const u8) void { const out = titleOutput(raw); var label_buffer: [terminal_title_max_label_bytes]u8 = undefined; const sanitized = sanitizedTerminalTitleLabel(label, &label_buffer); var sequence_buffer: [terminal_title_osc_prefix.len + terminal_title_max_content_bytes + 1]u8 = undefined; var sequence: std.Io.Writer = .fixed(&sequence_buffer); sequence.writeAll(terminal_title_osc_prefix) catch return; sequence.writeAll(sanitized) catch return; sequence.writeByte('\x07') catch return; out.writeStreamingAll(io_mod.getIo(), sequence.buffered()) catch return;}
fn clearTerminalTitleProvider(raw: ?*anyopaque) void { const out = titleOutput(raw); out.writeStreamingAll(io_mod.getIo(), "\x1b]2;\x07") catch return;}
test "terminal title writes the label to the caller's output file" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var sink = try tmp.dir.createFile(std.testing.io, "terminal-title.log", .{}); defer sink.close(io_mod.getIo());
// A host that redirects its output keeps the escape sequence off the // real stdout, which the Zig test runner owns as its protocol channel. terminalTitleFor(&sink).set("fx v" ++ main.version ++ " | fx");
var written_file = try tmp.dir.openFile(io_mod.getIo(), "terminal-title.log", .{}); defer written_file.close(io_mod.getIo()); const written = try io_mod.readFileToEnd(alloc, &written_file, 128); defer alloc.free(written); try std.testing.expectEqualStrings("\x1b]2;fx v" ++ main.version ++ " | fx\x07", written);}
test "terminal title sanitizes and bounds untrusted labels" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var sink = try tmp.dir.createFile(std.testing.io, "terminal-title-hostile.log", .{}); defer sink.close(io_mod.getIo());
terminalTitleFor(&sink).set("safe\x07\x1b]2;owned\xc2\x9b" ++ ("é" ** 80));
var written_file = try tmp.dir.openFile(io_mod.getIo(), "terminal-title-hostile.log", .{}); defer written_file.close(io_mod.getIo()); const written = try io_mod.readFileToEnd(alloc, &written_file, 512); defer alloc.free(written); try std.testing.expect(written.len <= terminal_title_osc_prefix.len + terminal_title_max_content_bytes + 1); try std.testing.expect(std.mem.startsWith(u8, written, "\x1b]2;safe]2;owned")); try std.testing.expect(std.mem.endsWith(u8, written, "...\x07")); try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, written, "\x07")); try std.testing.expect(std.mem.find(u8, written[terminal_title_osc_prefix.len..], "\x1b") == null); try std.testing.expect(std.mem.find(u8, written, "\xc2\x9b") == null);}
test "terminal title clear restores a predictable empty state" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var sink = try tmp.dir.createFile(std.testing.io, "terminal-title-clear.log", .{}); defer sink.close(io_mod.getIo());
terminalTitleFor(&sink).clear();
var written_file = try tmp.dir.openFile(io_mod.getIo(), "terminal-title-clear.log", .{}); defer written_file.close(io_mod.getIo()); const written = try io_mod.readFileToEnd(alloc, &written_file, 32); defer alloc.free(written); try std.testing.expectEqualStrings("\x1b]2;\x07", written);}
pub fn formatResumeHandoff( buffer: []u8, session_id: []const u8, terminal_cols: u16,) ![]const u8 { const label = "Continue session with:"; const command = "fx --resume "; const single_row_width = label.len + 1 + command.len + session_id.len; const separator = if (single_row_width <= terminal_cols) " " else "\n "; return std.fmt.bufPrint( buffer, "{s}{s}{s}{s}{s}{s}\n", .{ dim_style, label, separator, command, session_id, reset_style }, );}
test "initTheme sets light mode styles" { initTheme(true, null); try std.testing.expect(is_light); try std.testing.expect(!std.mem.eql(u8, subtitle_style, "\x1b[1;38;5;255m"));
initTheme(false, null); try std.testing.expect(!is_light); try std.testing.expectEqualStrings("\x1b[1;38;5;255m", subtitle_style);}
test "resume handoff uses one row only when the full instruction fits" { initTheme(false, null); defer initTheme(false, null);
const single_row = "Continue session with: fx --resume session-123"; var exact_buffer: [128]u8 = undefined; const exact = try formatResumeHandoff(&exact_buffer, "session-123", single_row.len); try std.testing.expectEqualStrings( "\x1b[38;5;245mContinue session with: fx --resume session-123\x1b[0m\n", exact, );
var narrow_buffer: [128]u8 = undefined; const narrow = try formatResumeHandoff(&narrow_buffer, "session-123", single_row.len - 1); try std.testing.expectEqualStrings( "\x1b[38;5;245mContinue session with:\n fx --resume session-123\x1b[0m\n", narrow, );}
test "resume handoff follows the active muted theme shade" { initTheme(true, null); defer initTheme(false, null);
var buffer: [128]u8 = undefined; const message = try formatResumeHandoff(&buffer, "session-123", 80); try std.testing.expectEqualStrings( "\x1b[38;5;247mContinue session with: fx --resume session-123\x1b[0m\n", message, );}
test "themeNeedsUpdate compares the active terminal background" { initTheme(false, .{ .r = 20, .g = 21, .b = 22 }); defer initTheme(false, null);
try std.testing.expect(!themeNeedsUpdate(false, null)); try std.testing.expect(!themeNeedsUpdate(false, .{ .r = 20, .g = 21, .b = 22 })); try std.testing.expect(themeNeedsUpdate(false, .{ .r = 21, .g = 21, .b = 22 })); try std.testing.expect(themeNeedsUpdate(true, null));}
test "initTheme selects the light inline code foreground" { const alloc = std.testing.allocator; assistant_presentation.setInlineCodeTheme(false); defer initTheme(false, null);
initTheme(true, null);
var processor = assistant_presentation.MarkdownProcessor{}; defer processor.deinit(alloc); var out: std.ArrayList(u8) = .empty; defer out.deinit(alloc);
try processor.push(alloc, "run `zig build` now\n", &out); try std.testing.expectEqualStrings("run \x1b[38;5;247mzig build\x1b[39m now\n", out.items);}
test "welcomeMessage shows version and help hint" { const message = try welcomeMessage(std.testing.allocator); defer std.testing.allocator.free(message);
try std.testing.expect(std.mem.find(u8, message, "𝒇x") != null); try std.testing.expect(std.mem.find(u8, message, main.version) != null); try std.testing.expect(std.mem.find(u8, message, "/help") != null);}
test "welcomeMessage keeps only the app name bright" { initTheme(false, null); const message = try welcomeMessage(std.testing.allocator); defer std.testing.allocator.free(message);
var label_buf: [welcome_build_label_bytes]u8 = undefined; const build_label = try writeBuildLabel( &label_buf, build_channel, main.version, build_options.git_commit, ); const expected = try std.fmt.allocPrint( std.testing.allocator, "{s}𝒇x{s}{s} {s} · Run /help for commands" ++ reset_style ++ "\n\n", .{ subtitle_style, reset_style, dim_style, build_label }, ); defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, message);}
test "build label stays bare on the stable channel" { var buf: [welcome_build_label_bytes]u8 = undefined; const label = try writeBuildLabel(&buf, .stable, "0.0.4", "abcdef123456"); try std.testing.expectEqualStrings("v0.0.4", label);}
test "dev build label carries the commit and restores the dim run after the tag" { initTheme(false, null);
var buf: [welcome_build_label_bytes]u8 = undefined; const label = try writeBuildLabel(&buf, .dev, "0.0.5", "abcdef123456");
const expected = try std.fmt.allocPrint( std.testing.allocator, "v0.0.5-abcdef1 {s}[dev]{s}", .{ hint_style, dim_style }, ); defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, label);}
test "dev build label drops an unresolved revision" { initTheme(false, null);
var buf: [welcome_build_label_bytes]u8 = undefined; const label = try writeBuildLabel(&buf, .dev, "0.0.5", "unknown");
const expected = try std.fmt.allocPrint( std.testing.allocator, "v0.0.5 {s}[dev]{s}", .{ hint_style, dim_style }, ); defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, label);}
test "buildHintLine does not advertise queue or alternate steering shortcuts" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{}, 120, &buf); try std.testing.expect(std.mem.find(u8, line, "enter queue") == null); try std.testing.expect(std.mem.find(u8, line, "ctrl+enter steer") == null);}
test "buildHintLine hides effort when it is auto" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "anthropic/claude-opus-4.7", .ask, false, .auto, true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.7", line);}
test "buildHintLine hides effort for models without effort support" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-4o", .ask, false, .auto, false, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-4o", line);}
test "buildHintLine uses a monochrome lightning marker for fast mode" { initTheme(false, null); defer initTheme(false, null);
var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "anthropic/claude-opus-4.8", .ask, true, types.ReasoningEffort.literal("low"), true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.8 · low · ⚡︎", line); try std.testing.expectEqual(@as(usize, 25), display_width.visibleWidthIgnoringAnsi(line));}
test "buildHintLine shows effort when active" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, types.ReasoningEffort.literal("high"), true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-5 · high", line);}
test "buildHintLine shows full context usage" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "anthropic/claude-opus-4.8", .ask, false, .auto, true, .{ .context_used = 43_000, .context_total = 1_000_000, }, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.8 · 43k/1000k 4%", line);}
test "buildHintLine shows context usage without a known total" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .context_used = 163_000, }, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-5 · 163k", line);}
test "buildHintLine shows the session title" { var buf: [256]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .session_title = "add a session name display", }, 200, &buf); try std.testing.expectEqualStrings( "ask · gpt-5 · add a session name display", line, );}
test "buildHintLine clips an overlong session title on a character boundary" { var buf: [256]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .session_title = "ααααααααααααααααααααααααααααααααααααααααα", }, 200, &buf); try std.testing.expect(std.mem.startsWith(u8, line, "ask · gpt-5 · ")); const title = line["ask · gpt-5 · ".len..]; try std.testing.expect(std.unicode.utf8ValidateSlice(title)); try std.testing.expectEqual(@as(usize, 32), display_width.visibleWidth(title));}
test "buildHintLine omits the session segment when no title is cached" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .session_title = null, }, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-5", line);}
test "buildHintLine shows the workspace and Git branch" { var buf: [256]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .workspace_label = "/workspace/code/fx", .git_branch = "feature/statusline", }, 100, &buf); try std.testing.expectEqualStrings( "ask · gpt-5 · /workspace/code/fx (feature/statusline)", line, );}
test "buildHintLine keeps workspace and branch readable at narrow widths" { var buf: [256]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .workspace_label = "/a/very/long/path/to/fx-repo", .git_branch = "feature/statusline", }, 36, &buf); try std.testing.expectEqual(@as(usize, 36), display_width.visibleWidthIgnoringAnsi(line)); try std.testing.expect(std.mem.startsWith(u8, line, "ask · gpt-5 · ")); try std.testing.expect(std.mem.find(u8, line, "fx-repo") != null); try std.testing.expect(std.mem.find(u8, line, "feature/") != null); try std.testing.expect(std.mem.endsWith(u8, line, "…)"));}
test "buildHintLine workspace identity does not displace existing status segments" { var buf: [256]u8 = undefined; const line = buildHintLine(false, true, "anthropic/claude-opus-4.8", .auto, true, types.ReasoningEffort.literal("xhigh"), true, .{ .workspace_label = "/a/very/long/path/to/the/active/workspace", .git_branch = "feature/statusline", .context_used = 1_000, .context_total = 100_000, }, 60, &buf); try std.testing.expect(std.mem.find(u8, line, "xhigh") != null); try std.testing.expect(std.mem.find(u8, line, "⚡︎") != null); try std.testing.expect(std.mem.find(u8, line, "1k/100k 1%") != null);}
test "buildHintLine shows a non-Git workspace without branch punctuation" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .workspace_label = "/tmp/plain-workspace", }, 80, &buf); try std.testing.expectEqualStrings( "ask · gpt-5 · /tmp/plain-workspace", line, );}
test "buildHintLine labels detached HEAD" { var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-5", .ask, false, .auto, false, .{ .workspace_label = "/tmp/fx", .git_branch = "detached:0123456789ab", }, 80, &buf); try std.testing.expectEqualStrings( "ask · gpt-5 · /tmp/fx (detached:0123456789ab)", line, );}
test "buildHintLine keeps system labels and dot separators" { var buf: [256]u8 = undefined; const line = buildHintLine(false, false, "anthropic/claude-opus-4.8", .auto, true, types.ReasoningEffort.literal("low"), true, .{ .context_used = 43_000, .context_total = 1_000_000, }, 256, &buf); const expected = try std.fmt.allocPrint( std.testing.allocator, "run /login · {s}auto{s} · opus 4.8 · low · ⚡︎ · 43k/1000k 4%", .{ permission_auto_style, statusline_style }, ); defer std.testing.allocator.free(expected); try std.testing.expectEqualStrings( expected, line, );}
test "buildHintLine skips an over-capacity segment without a dangling dot" { var buf: [16]u8 = undefined; const line = buildHintLine(false, true, "anthropic/claude-opus-4.7", .ask, true, .auto, true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.7", line);}
test "buildHintLine colors auto mode with theme accent" { initTheme(false, null); const dark_accent = permission_auto_style; const dark_status = statusline_style; var dark_buf: [128]u8 = undefined; const dark_line = buildHintLine(false, true, "openai/gpt-4o", .auto, false, .auto, false, .{}, 80, &dark_buf); const dark_expected = try std.fmt.allocPrint(std.testing.allocator, "{s}auto{s} · gpt-4o", .{ dark_accent, dark_status }); defer std.testing.allocator.free(dark_expected); try std.testing.expectEqualStrings(dark_expected, dark_line);
initTheme(true, null); defer initTheme(false, null); try std.testing.expect(!std.mem.eql(u8, permission_auto_style, dark_accent)); var light_buf: [128]u8 = undefined; const light_line = buildHintLine(false, true, "openai/gpt-4o", .auto, false, .auto, false, .{}, 80, &light_buf); const light_expected = try std.fmt.allocPrint(std.testing.allocator, "{s}auto{s} · gpt-4o", .{ permission_auto_style, statusline_style }); defer std.testing.allocator.free(light_expected); try std.testing.expectEqualStrings(light_expected, light_line);}
test "buildHintLine renders full access with subdued permission styling" { initTheme(false, null); var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-4o", .yolo, false, .auto, false, .{}, 80, &buf); const expected = try std.fmt.allocPrint( std.testing.allocator, "{s}full access{s} · gpt-4o", .{ permission_auto_style, statusline_style }, ); defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, line);}
test "buildHintLine clips styled auto mode by visible width" { initTheme(false, null); defer initTheme(false, null);
var buf: [128]u8 = undefined; const line = buildHintLine(false, true, "openai/gpt-4o", .auto, false, .auto, false, .{}, 13, &buf); const expected = try std.fmt.allocPrint(std.testing.allocator, "{s}auto{s} · gpt-4o", .{ permission_auto_style, statusline_style }); defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, line); try std.testing.expectEqual(@as(usize, 13), display_width.visibleWidthIgnoringAnsi(line)); try std.testing.expect(std.mem.endsWith(u8, line, "gpt-4o"));}
test "buildSessionStatusLine reuses model effort and context formatting" { var buf: [128]u8 = undefined; const line = buildSessionStatusLine( "google/gemini-3.8-flash", types.ReasoningEffort.literal("high"), true, .{ .context_used = 12_000, .context_total = 100_000 }, 100, &buf, ); try std.testing.expectEqualStrings("gemini-3.8-flash · high · 12k/100k 12%", line);}