@oh-my-pi/pi-tui 18.3.2 → 18.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/types/app-keybindings.d.ts +2 -17
- package/dist/types/autocomplete.d.ts +18 -3
- package/dist/types/chrome/keybinding-hints.d.ts +16 -10
- package/dist/types/components/editor.d.ts +11 -0
- package/dist/types/key-hint-format.d.ts +29 -0
- package/dist/types/keys.d.ts +2 -1
- package/dist/types/overlays/agent-hub.d.ts +1 -1
- package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -1
- package/dist/types/overlays/model-picker.d.ts +1 -3
- package/dist/types/prompt/composer-hints.d.ts +42 -0
- package/dist/types/prompt/composer.d.ts +2 -1
- package/dist/types/prompt/custom-editor.d.ts +8 -2
- package/dist/types/prompt/macos-spelling.d.ts +7 -16
- package/dist/types/prompt/prompt-action-autocomplete.d.ts +4 -1
- package/dist/types/prompt/prose-gate.d.ts +24 -0
- package/dist/types/prompt/word-completion.d.ts +46 -0
- package/dist/types/render/render-utils.d.ts +1 -1
- package/dist/types/theme/active-symbols.d.ts +12 -0
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +0 -1
- package/dist/types/tools/eval.d.ts +1 -1
- package/dist/types/tools/mcp.d.ts +3 -1
- package/dist/types/tools/task-yield-assembly.d.ts +10 -2
- package/dist/types/tools/task.d.ts +4 -0
- package/package.json +11 -10
- package/src/app-keybindings.ts +11 -104
- package/src/apps/autoresearch-dashboard.ts +10 -3
- package/src/apps/debug/log-viewer.ts +3 -2
- package/src/apps/debug/raw-sse.ts +2 -1
- package/src/apps/git/git-tui.ts +9 -5
- package/src/apps/live-visualizer.ts +2 -1
- package/src/apps/ps-top.ts +13 -4
- package/src/autocomplete.ts +42 -7
- package/src/chat/compaction-summary-message.ts +2 -1
- package/src/chat/execution-shared.ts +4 -2
- package/src/chat/tool-execution.ts +3 -1
- package/src/chat/ttsr-notification.ts +4 -3
- package/src/chat/user-message.ts +2 -2
- package/src/chrome/keybinding-hints.ts +38 -13
- package/src/components/editor.ts +180 -58
- package/src/components/settings-list.ts +10 -3
- package/src/components/tab-bar.ts +2 -1
- package/src/debug-server.ts +4 -1
- package/src/hotkeys-markdown.ts +14 -17
- package/src/key-hint-format.ts +122 -0
- package/src/keys.ts +2 -1
- package/src/overlays/advisor-config.ts +28 -6
- package/src/overlays/agent-hub.ts +8 -5
- package/src/overlays/agent-transcript-viewer.ts +6 -4
- package/src/overlays/agents-hub.ts +22 -8
- package/src/overlays/annotation-overlay.ts +31 -17
- package/src/overlays/ask-dialog.ts +14 -19
- package/src/overlays/bordered-loader.ts +2 -1
- package/src/overlays/btw-history-panel.ts +15 -12
- package/src/overlays/btw-panel.ts +12 -7
- package/src/overlays/cleanse-panel.ts +9 -6
- package/src/overlays/codex-reset-fireworks.ts +5 -3
- package/src/overlays/copy-selector.ts +13 -5
- package/src/overlays/extensions/extension-dashboard.ts +6 -1
- package/src/overlays/history-search.ts +3 -2
- package/src/overlays/hook-editor.ts +7 -2
- package/src/overlays/hook-input.ts +2 -1
- package/src/overlays/hook-selector.ts +5 -1
- package/src/overlays/login-dialog.ts +17 -4
- package/src/overlays/logout-account-selector.ts +10 -1
- package/src/overlays/mcp-add-wizard.ts +32 -20
- package/src/overlays/model-hub.ts +55 -28
- package/src/overlays/model-picker.ts +27 -12
- package/src/overlays/move-overlay.ts +5 -1
- package/src/overlays/omfg-panel.ts +8 -5
- package/src/overlays/pause-screen.ts +7 -3
- package/src/overlays/plan-review-overlay.ts +40 -14
- package/src/overlays/plan-save-overlay.ts +3 -1
- package/src/overlays/plugin-settings.ts +15 -8
- package/src/overlays/reset-usage-selector.ts +10 -3
- package/src/overlays/rewind-selector.ts +11 -5
- package/src/overlays/session-info-overlay.ts +4 -3
- package/src/overlays/session-selector.ts +12 -2
- package/src/overlays/settings-selector.ts +18 -11
- package/src/overlays/tree-selector.ts +27 -4
- package/src/overlays/usage-dashboard.ts +8 -2
- package/src/prompt/composer-cache.ts +2 -1
- package/src/prompt/composer-hints.ts +75 -0
- package/src/prompt/composer.ts +3 -2
- package/src/prompt/custom-editor.ts +22 -4
- package/src/prompt/macos-spelling.ts +17 -158
- package/src/prompt/prompt-action-autocomplete.ts +9 -2
- package/src/prompt/prose-gate.ts +87 -0
- package/src/prompt/tips.txt +6 -6
- package/src/prompt/welcome.ts +35 -2
- package/src/prompt/word-completion.ts +205 -0
- package/src/render/render-utils.ts +4 -4
- package/src/setup/scenes/composer.ts +5 -1
- package/src/setup/scenes/model.ts +3 -1
- package/src/setup/scenes/providers.ts +4 -1
- package/src/setup/scenes/sign-in.ts +4 -2
- package/src/setup/scenes/splash.ts +9 -4
- package/src/setup/scenes/theme.ts +18 -5
- package/src/setup/wizard-overlay.ts +5 -4
- package/src/theme/active-symbols.ts +20 -0
- package/src/theme/glyph-bundle.json +28 -1
- package/src/theme/symbols.ts +88 -6
- package/src/theme/theme-class.ts +0 -1
- package/src/theme/theme.ts +2 -0
- package/src/tools/bash-interactive.ts +2 -1
- package/src/tools/eval.ts +3 -3
- package/src/tools/mcp.ts +3 -1
- package/src/tools/task-yield-assembly.ts +34 -8
- package/src/tools/task.ts +14 -5
- package/dist/types/overlays/tiny-title-download-progress.d.ts +0 -21
- package/src/overlays/tiny-title-download-progress.ts +0 -102
|
@@ -19,7 +19,8 @@ import { MODEL_KINDS, modelKind, type ModelKind } from "@oh-my-pi/pi-catalog/typ
|
|
|
19
19
|
import type { Component, TUI } from "../tui";
|
|
20
20
|
import { extractPrintableText, matchesKey } from "../keys";
|
|
21
21
|
import { fuzzyFilter } from "../fuzzy";
|
|
22
|
-
import {
|
|
22
|
+
import { formatKeyHint, formatKeyHints } from "../app-keybindings";
|
|
23
|
+
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
23
24
|
import { Input } from "../components/input";
|
|
24
25
|
import { routeSgrMouseInput, type SgrMouseEvent } from "../mouse";
|
|
25
26
|
import { truncateToWidth, visibleWidth } from "../utils";
|
|
@@ -2013,7 +2014,10 @@ export class ModelHubComponent implements Component {
|
|
|
2013
2014
|
MODEL_KIND_TABS.map(kind => ({ label: kind })),
|
|
2014
2015
|
Math.max(0, active),
|
|
2015
2016
|
);
|
|
2016
|
-
return truncateToWidth(
|
|
2017
|
+
return truncateToWidth(
|
|
2018
|
+
` ${theme.fg("dim", "Kind:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}`,
|
|
2019
|
+
width,
|
|
2020
|
+
);
|
|
2017
2021
|
}
|
|
2018
2022
|
|
|
2019
2023
|
#renderRoleTabs(width: number): string {
|
|
@@ -2022,7 +2026,10 @@ export class ModelHubComponent implements Component {
|
|
|
2022
2026
|
ROLE_TABS.map(tab => ({ label: tab === "kind" ? "kinds" : tab })),
|
|
2023
2027
|
Math.max(0, active),
|
|
2024
2028
|
);
|
|
2025
|
-
return truncateToWidth(
|
|
2029
|
+
return truncateToWidth(
|
|
2030
|
+
` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}`,
|
|
2031
|
+
width,
|
|
2032
|
+
);
|
|
2026
2033
|
}
|
|
2027
2034
|
|
|
2028
2035
|
#statusRow(width: number): string {
|
|
@@ -2030,9 +2037,11 @@ export class ModelHubComponent implements Component {
|
|
|
2030
2037
|
return truncateToWidth(theme.fg("accent", " Applying model…"), width);
|
|
2031
2038
|
}
|
|
2032
2039
|
if (this.#assigning !== null) {
|
|
2040
|
+
const enter = formatKeyHint("enter");
|
|
2041
|
+
const cancel = editorKey("tui.select.cancel");
|
|
2033
2042
|
if (this.#assigning.kind === "fallbackKey") {
|
|
2034
2043
|
return truncateToWidth(
|
|
2035
|
-
theme.fg("accent",
|
|
2044
|
+
theme.fg("accent", ` New fallback chain — ${enter} picks the model it protects, ${cancel} cancels`),
|
|
2036
2045
|
width,
|
|
2037
2046
|
);
|
|
2038
2047
|
}
|
|
@@ -2041,12 +2050,15 @@ export class ModelHubComponent implements Component {
|
|
|
2041
2050
|
if (this.#assigning.kind === "fallback") {
|
|
2042
2051
|
const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
|
|
2043
2052
|
return truncateToWidth(
|
|
2044
|
-
theme.fg(
|
|
2053
|
+
theme.fg(
|
|
2054
|
+
"accent",
|
|
2055
|
+
` ${verb} ${theme.bold(label)} — ${enter} picks the fallback model, ${cancel} cancels`,
|
|
2056
|
+
),
|
|
2045
2057
|
width,
|
|
2046
2058
|
);
|
|
2047
2059
|
}
|
|
2048
2060
|
return truncateToWidth(
|
|
2049
|
-
theme.fg("accent", ` Assigning ${theme.bold(label)} —
|
|
2061
|
+
theme.fg("accent", ` Assigning ${theme.bold(label)} — ${enter} assigns, ${cancel} cancels`),
|
|
2050
2062
|
width,
|
|
2051
2063
|
);
|
|
2052
2064
|
}
|
|
@@ -2058,7 +2070,7 @@ export class ModelHubComponent implements Component {
|
|
|
2058
2070
|
text = `Recently used models${scopedSuffix}`;
|
|
2059
2071
|
break;
|
|
2060
2072
|
case "roles":
|
|
2061
|
-
text =
|
|
2073
|
+
text = `Model roles — ${formatKeyHint("f")} adds a retry fallback, cleared roles fall back to auto-selection`;
|
|
2062
2074
|
break;
|
|
2063
2075
|
case "provider":
|
|
2064
2076
|
if (entry.locked) {
|
|
@@ -2213,7 +2225,7 @@ export class ModelHubComponent implements Component {
|
|
|
2213
2225
|
// segment track the ctrl+p status uses; the selected role's chip fills.
|
|
2214
2226
|
while (lines.length < rows - 1) lines.push("");
|
|
2215
2227
|
if (rows >= 2) {
|
|
2216
|
-
const cycleKey =
|
|
2228
|
+
const cycleKey = editorKey("app.model.cycleForward") || formatKeyHint("ctrl+p");
|
|
2217
2229
|
if (cycleOrder.length > 0) {
|
|
2218
2230
|
const selectedRow = this.#rolesRows[this.#roleIndex];
|
|
2219
2231
|
const selectedRole =
|
|
@@ -2226,7 +2238,7 @@ export class ModelHubComponent implements Component {
|
|
|
2226
2238
|
lines[rows - 1] = truncateToWidth(` ${theme.fg("dim", `${cycleKey} cycle:`)} ${track}`, width);
|
|
2227
2239
|
} else {
|
|
2228
2240
|
lines[rows - 1] = truncateToWidth(
|
|
2229
|
-
theme.fg("dim", ` ${cycleKey} cycle is empty — press c on a role to add it`),
|
|
2241
|
+
theme.fg("dim", ` ${cycleKey} cycle is empty — press ${formatKeyHint("c")} on a role to add it`),
|
|
2230
2242
|
width,
|
|
2231
2243
|
);
|
|
2232
2244
|
}
|
|
@@ -2253,7 +2265,12 @@ export class ModelHubComponent implements Component {
|
|
|
2253
2265
|
}
|
|
2254
2266
|
if (entry.oauth) {
|
|
2255
2267
|
this.#lockedLoginLine = lines.length + 1; // +1 for the status row offset handled by caller
|
|
2256
|
-
lines.push(
|
|
2268
|
+
lines.push(
|
|
2269
|
+
truncateToWidth(
|
|
2270
|
+
theme.fg("accent", ` ${theme.nav.cursor} Log in with OAuth (${formatKeyHint("enter")})`),
|
|
2271
|
+
width,
|
|
2272
|
+
),
|
|
2273
|
+
);
|
|
2257
2274
|
}
|
|
2258
2275
|
lines.push("");
|
|
2259
2276
|
const catalogCount = entry.catalogCount ?? 0;
|
|
@@ -2271,32 +2288,40 @@ export class ModelHubComponent implements Component {
|
|
|
2271
2288
|
}
|
|
2272
2289
|
|
|
2273
2290
|
#footerHint(): string {
|
|
2291
|
+
const enter = formatKeyHint("enter");
|
|
2292
|
+
const cancel = editorKey("tui.select.cancel");
|
|
2293
|
+
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
2294
|
+
const left = formatKeyHint("left");
|
|
2295
|
+
const leftRight = formatKeyHints(["left", "right"]);
|
|
2296
|
+
const enterRight = formatKeyHints(["enter", "right"]);
|
|
2297
|
+
const altLeftRight = formatKeyHints(["alt+left", "alt+right"]);
|
|
2274
2298
|
const strip = this.#strip;
|
|
2275
2299
|
if (strip) {
|
|
2276
2300
|
if (strip.kind === "roleName") {
|
|
2277
|
-
return
|
|
2301
|
+
return `${enter} create + pick model · ${cancel} cancel`;
|
|
2278
2302
|
}
|
|
2279
|
-
if (strip.kind === "role") return
|
|
2280
|
-
if (strip.kind === "scope") return
|
|
2281
|
-
return
|
|
2303
|
+
if (strip.kind === "role") return `${leftRight} choose · ${enter} assign/clear · ${cancel} cancel`;
|
|
2304
|
+
if (strip.kind === "scope") return `${leftRight} save scope · ${enter} choose · ${cancel} cancel`;
|
|
2305
|
+
return `${leftRight} thinking level · ${enter} apply · ${cancel} keep`;
|
|
2282
2306
|
}
|
|
2283
2307
|
if (this.#assigning !== null) {
|
|
2284
2308
|
if (this.#focus === "scope") {
|
|
2285
|
-
return
|
|
2309
|
+
return `${enterRight} models · ${upDown} providers · type to search · ${altLeftRight} kind · ${cancel} cancel`;
|
|
2286
2310
|
}
|
|
2311
|
+
const browse = `${upDown} models · ${left} providers · type to search · ${altLeftRight} kind · ${cancel} cancel`;
|
|
2287
2312
|
switch (this.#assigning.kind) {
|
|
2288
2313
|
case "fallback":
|
|
2289
|
-
return
|
|
2314
|
+
return `${enter} pick fallback · ${browse}`;
|
|
2290
2315
|
case "fallbackKey":
|
|
2291
|
-
return
|
|
2316
|
+
return `${enter} pick the protected model · ${browse}`;
|
|
2292
2317
|
default:
|
|
2293
|
-
return
|
|
2318
|
+
return `${enter} assign · ${browse}`;
|
|
2294
2319
|
}
|
|
2295
2320
|
}
|
|
2296
2321
|
const entry = this.#activeEntry();
|
|
2297
2322
|
if (entry.kind === "roles") {
|
|
2298
2323
|
if (this.#focus !== "list") {
|
|
2299
|
-
return
|
|
2324
|
+
return `${upDown} providers · ${enterRight} roles · ${altLeftRight} tabs · ${cancel} close`;
|
|
2300
2325
|
}
|
|
2301
2326
|
const row = this.#rolesRows[this.#roleIndex];
|
|
2302
2327
|
if (row?.kind === "fallback") {
|
|
@@ -2304,25 +2329,27 @@ export class ModelHubComponent implements Component {
|
|
|
2304
2329
|
// inherit and unknown models have no ladder to offer, so the
|
|
2305
2330
|
// action would be inert there.
|
|
2306
2331
|
const editable = this.#resolveFallbackEntry(row.role, row.chainIndex) !== undefined;
|
|
2307
|
-
const thinking = editable ?
|
|
2308
|
-
return
|
|
2332
|
+
const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
|
|
2333
|
+
return `${upDown} rows · ${enter} replace · ${formatKeyHint("f")} add another · ${formatKeyHint("x")} remove${thinking} · [/] reorder · ${left} providers`;
|
|
2309
2334
|
}
|
|
2310
2335
|
if (row?.kind === "chainKey") {
|
|
2311
|
-
return
|
|
2336
|
+
return `${upDown} rows · ${formatKeyHints(["enter", "f"])} add fallback · ${formatKeyHint("x")} clear chain · ${left} providers`;
|
|
2312
2337
|
}
|
|
2313
2338
|
if (row?.kind === "newFallback") {
|
|
2314
|
-
return
|
|
2339
|
+
return `${upDown} rows · ${enter} new model/provider fallback chain · ${left} providers`;
|
|
2315
2340
|
}
|
|
2316
|
-
return
|
|
2341
|
+
return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear · ${formatKeyHint("t")} thinking · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
|
|
2317
2342
|
}
|
|
2318
2343
|
if (entry.kind === "provider" && entry.locked) {
|
|
2319
|
-
return entry.oauth
|
|
2344
|
+
return entry.oauth
|
|
2345
|
+
? `${enter} log in · ${upDown} providers · ${cancel} close`
|
|
2346
|
+
: `${upDown} providers · ${cancel} close`;
|
|
2320
2347
|
}
|
|
2321
|
-
const refresh = entry.kind === "provider" ?
|
|
2348
|
+
const refresh = entry.kind === "provider" ? ` · ${formatKeyHint("f5")} refresh` : "";
|
|
2322
2349
|
if (this.#focus === "scope") {
|
|
2323
|
-
return
|
|
2350
|
+
return `${enterRight} models · ${upDown} providers · type to search · ${altLeftRight} kind${refresh} · ${cancel} close`;
|
|
2324
2351
|
}
|
|
2325
|
-
return
|
|
2352
|
+
return `${enter} assign roles · ${upDown} models · ${left} providers · type to search · ${altLeftRight} kind${refresh} · ${cancel} close`;
|
|
2326
2353
|
}
|
|
2327
2354
|
|
|
2328
2355
|
#renderFooter(width: number): string {
|
|
@@ -15,6 +15,8 @@ import type { ConfiguredThinkingLevel } from "../thinking";
|
|
|
15
15
|
import type { ScopedModelItem } from "./model-hub";
|
|
16
16
|
import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
|
|
17
17
|
import { resolveSegmentPalette } from "../chrome/segment-track";
|
|
18
|
+
import { formatKeyHint } from "../app-keybindings";
|
|
19
|
+
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
18
20
|
|
|
19
21
|
/** Configured role resolved to a concrete model. */
|
|
20
22
|
export interface ResolvedRoleModel {
|
|
@@ -58,10 +60,8 @@ export interface ModelPickerOptions {
|
|
|
58
60
|
quickRoleOrder?: ReadonlyArray<string>;
|
|
59
61
|
/** Active quick role, highlighted when the search begins with `@`. */
|
|
60
62
|
currentQuickRole?: string;
|
|
61
|
-
/** Keys that toggle task-subagent mode while the picker is open;
|
|
63
|
+
/** Keys that toggle task-subagent mode while the picker is open; the first is shown in the footer. */
|
|
62
64
|
taskModeKeys?: readonly KeyId[];
|
|
63
|
-
/** Human-readable label for the toggle key, shown in footer hints (e.g. "alt+p"). */
|
|
64
|
-
taskModeKeyLabel?: string;
|
|
65
65
|
/** `provider/id` highlighted and preselected in task mode (current Task subagent model). */
|
|
66
66
|
taskSelector?: string;
|
|
67
67
|
}
|
|
@@ -78,9 +78,21 @@ const HEIGHT_FRACTION = 0.4;
|
|
|
78
78
|
const STATUS_HINT = "Session-only switch — role models stay unchanged";
|
|
79
79
|
const QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session";
|
|
80
80
|
const TASK_STATUS_HINT = "Task subagent switch — spawned task agents use this model (session-only)";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
|
|
82
|
+
/** Footer hint for the active mode; keys resolve at render time so theme/keybinding changes apply. */
|
|
83
|
+
function footerHint(mode: "session" | "role" | "task"): string {
|
|
84
|
+
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
85
|
+
const enter = formatKeyHint("enter");
|
|
86
|
+
const close = `${editorKey("tui.select.cancel")} close`;
|
|
87
|
+
switch (mode) {
|
|
88
|
+
case "role":
|
|
89
|
+
return `${upDown} roles · ${enter} apply role model · type to search · ${close}`;
|
|
90
|
+
case "task":
|
|
91
|
+
return `${upDown} models · ${enter} use for Task subagents · type to search · ${close}`;
|
|
92
|
+
default:
|
|
93
|
+
return `${upDown} models · ${enter} use for this session · type to search · @ quick roles · ${close}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
84
96
|
|
|
85
97
|
/**
|
|
86
98
|
* The alt+p picker component. Hosted as a non-fullscreen bottom-anchored
|
|
@@ -102,7 +114,7 @@ export class ModelPickerComponent implements Component {
|
|
|
102
114
|
#roleMode = false;
|
|
103
115
|
#taskMode = false;
|
|
104
116
|
#taskMatchKeys = new Set<string>();
|
|
105
|
-
#
|
|
117
|
+
#taskModeKey: KeyId | undefined;
|
|
106
118
|
#taskSelector: string | undefined;
|
|
107
119
|
|
|
108
120
|
constructor(
|
|
@@ -120,8 +132,8 @@ export class ModelPickerComponent implements Component {
|
|
|
120
132
|
this.#currentSelector = options.currentSelector;
|
|
121
133
|
this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined;
|
|
122
134
|
this.#taskSelector = options.taskSelector;
|
|
123
|
-
this.#taskModeKeyLabel = options.taskModeKeyLabel ?? "alt+p";
|
|
124
135
|
if (callbacks.onPickTask) {
|
|
136
|
+
this.#taskModeKey = options.taskModeKeys?.[0];
|
|
125
137
|
for (const key of options.taskModeKeys ?? []) addKeyAliases(this.#taskMatchKeys, key);
|
|
126
138
|
}
|
|
127
139
|
this.#quickRoleItems = this.#buildQuickRoleItems(
|
|
@@ -132,7 +144,10 @@ export class ModelPickerComponent implements Component {
|
|
|
132
144
|
this.#browser = new ModelBrowser(settings, {
|
|
133
145
|
currentContextTokens: options.currentContextTokens,
|
|
134
146
|
markOverContext: true,
|
|
135
|
-
emptyText: () =>
|
|
147
|
+
emptyText: () =>
|
|
148
|
+
this.#roleMode
|
|
149
|
+
? ` No quick roles in the ${editorKey("app.model.cycleForward") || formatKeyHint("ctrl+p")} cycle`
|
|
150
|
+
: undefined,
|
|
136
151
|
});
|
|
137
152
|
this.#browser.onActivate = item => {
|
|
138
153
|
const quickRole = this.#quickRoles.get(item.selector);
|
|
@@ -267,9 +282,9 @@ export class ModelPickerComponent implements Component {
|
|
|
267
282
|
: theme.fg("muted", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`);
|
|
268
283
|
|
|
269
284
|
const borderColor: ThemeColor | undefined = this.#taskMode ? "error" : undefined;
|
|
270
|
-
let footer = this.#taskMode ?
|
|
271
|
-
if (this.#
|
|
272
|
-
footer += ` · ${this.#
|
|
285
|
+
let footer = footerHint(this.#taskMode ? "task" : this.#roleMode ? "role" : "session");
|
|
286
|
+
if (this.#taskModeKey !== undefined && !this.#roleMode) {
|
|
287
|
+
footer += ` · ${formatKeyHint(this.#taskModeKey)} ${this.#taskMode ? "session model" : "task model"}`;
|
|
273
288
|
}
|
|
274
289
|
|
|
275
290
|
const out: string[] = [];
|
|
@@ -11,6 +11,8 @@ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keyb
|
|
|
11
11
|
import { formTheme } from "../chrome/form-theme";
|
|
12
12
|
import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
|
|
13
13
|
import { TextFormField } from "../components/form";
|
|
14
|
+
import { formatKeyHint } from "../app-keybindings";
|
|
15
|
+
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
14
16
|
|
|
15
17
|
export interface MoveOverlayResult {
|
|
16
18
|
directory: string;
|
|
@@ -161,7 +163,9 @@ export class MoveOverlay implements Component, Focusable {
|
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
lines.push(row("", w));
|
|
164
|
-
|
|
166
|
+
const nav = editorKeys("tui.select.up", "tui.select.down");
|
|
167
|
+
const hint = `Type to filter · ${nav} navigate · ${formatKeyHint("tab")} accept · ${formatKeyHint("enter")} confirm · ${editorKey("tui.select.cancel")} cancel`;
|
|
168
|
+
lines.push(row(theme.fg("dim", hint), w));
|
|
165
169
|
lines.push(bottomBorder(w));
|
|
166
170
|
this.#renderMemo = { width: w, fieldLines, revision: this.#revision, lines };
|
|
167
171
|
return lines;
|
|
@@ -3,6 +3,7 @@ import { replaceTabs } from "../render/render-utils";
|
|
|
3
3
|
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
4
4
|
import { OverlayPanel } from "../chrome/overlay-box";
|
|
5
5
|
import { StreamingPanelContent } from "../chrome/streaming-panel";
|
|
6
|
+
import { interruptKey } from "../chrome/keybinding-hints";
|
|
6
7
|
|
|
7
8
|
export type OmfgPanelState =
|
|
8
9
|
| "generating"
|
|
@@ -103,23 +104,25 @@ export class OmfgPanelComponent extends OverlayPanel {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
#footerLine(): string {
|
|
107
|
+
// The composer's `app.interrupt` handler routes Esc to this panel.
|
|
108
|
+
const esc = interruptKey();
|
|
106
109
|
switch (this.#state) {
|
|
107
110
|
case "generating":
|
|
108
111
|
case "validating":
|
|
109
112
|
case "confirming":
|
|
110
113
|
case "saving":
|
|
111
|
-
return theme.fg("muted",
|
|
114
|
+
return theme.fg("muted", `${esc} cancel /omfg`);
|
|
112
115
|
case "saved":
|
|
113
116
|
return theme.fg(
|
|
114
117
|
"success",
|
|
115
|
-
`${theme.status.success} Registered live · ${replaceTabs(this.#savedPath ?? "saved")} ·
|
|
118
|
+
`${theme.status.success} Registered live · ${replaceTabs(this.#savedPath ?? "saved")} · ${esc} dismiss`,
|
|
116
119
|
);
|
|
117
120
|
case "rejected":
|
|
118
|
-
return theme.fg("warning", `${theme.status.warning} Not saved ·
|
|
121
|
+
return theme.fg("warning", `${theme.status.warning} Not saved · ${esc} dismiss`);
|
|
119
122
|
case "aborted":
|
|
120
|
-
return theme.fg("warning", `${theme.status.warning} Cancelled ·
|
|
123
|
+
return theme.fg("warning", `${theme.status.warning} Cancelled · ${esc} dismiss`);
|
|
121
124
|
case "error":
|
|
122
|
-
return theme.fg("error", `${theme.status.error} Error ·
|
|
125
|
+
return theme.fg("error", `${theme.status.error} Error · ${esc} dismiss`);
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
|
|
@@ -17,6 +17,8 @@ import { formatCoarseDuration } from "../chrome/format";
|
|
|
17
17
|
import { centerLine } from "../utils";
|
|
18
18
|
import { theme } from "../theme/theme";
|
|
19
19
|
import { matchesAppInterrupt } from "../keybinding-matchers";
|
|
20
|
+
import { formatKeyHint } from "../app-keybindings";
|
|
21
|
+
import { interruptKey } from "../chrome/keybinding-hints";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
|
|
@@ -50,7 +52,6 @@ const BODY_LINES = [
|
|
|
50
52
|
"Main agent, subagents, and advisor hold at their next step.",
|
|
51
53
|
"In-flight calls finish; nothing new starts until you resume.",
|
|
52
54
|
] as const;
|
|
53
|
-
const RESUME_HINT = "esc · enter · space — resume";
|
|
54
55
|
|
|
55
56
|
/** Live hold clock, seconds-precise: `0:07`, `12:34`, `1:02:03`. */
|
|
56
57
|
function formatClock(ms: number): string {
|
|
@@ -69,6 +70,8 @@ function formatClock(ms: number): string {
|
|
|
69
70
|
export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
|
|
70
71
|
const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
|
|
71
72
|
const content: string[] = [];
|
|
73
|
+
// Resume keys mirror PauseScreenComponent#handleInput: `app.interrupt` (raw Escape when unbound), Enter, Space.
|
|
74
|
+
const esc = interruptKey();
|
|
72
75
|
|
|
73
76
|
if (compact) {
|
|
74
77
|
if (sessionName) {
|
|
@@ -78,7 +81,7 @@ export function renderPauseScreen(width: number, height: number, elapsedMs: numb
|
|
|
78
81
|
content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width).trimEnd());
|
|
79
82
|
content.push("");
|
|
80
83
|
content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
|
|
81
|
-
content.push(centerLine(theme.fg("dim",
|
|
84
|
+
content.push(centerLine(theme.fg("dim", `${esc} to resume`), width).trimEnd());
|
|
82
85
|
} else {
|
|
83
86
|
if (sessionName) {
|
|
84
87
|
content.push(centerLine(theme.bold(sessionName), width).trimEnd());
|
|
@@ -99,7 +102,8 @@ export function renderPauseScreen(width: number, height: number, elapsedMs: numb
|
|
|
99
102
|
content.push("");
|
|
100
103
|
content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
|
|
101
104
|
content.push("");
|
|
102
|
-
|
|
105
|
+
const resumeHint = `${esc} · ${formatKeyHint("enter")} · ${formatKeyHint("space")} — resume`;
|
|
106
|
+
content.push(centerLine(theme.fg("dim", resumeHint), width).trimEnd());
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
const topPad = Math.max(0, Math.floor((height - content.length) / 2));
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
import { joinPlanSections, parsePlanSections, sectionDeletionSpan } from "./plan-toc";
|
|
53
53
|
import { padToWidth } from "../render/utils";
|
|
54
54
|
import { renderSegmentTrack } from "../chrome/segment-track";
|
|
55
|
+
import { formatKeyHint, formatKeyHints } from "../app-keybindings";
|
|
56
|
+
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
55
57
|
|
|
56
58
|
/** Title shown in the overlay's top border. */
|
|
57
59
|
const OVERLAY_TITLE = "Plan Review";
|
|
@@ -166,9 +168,6 @@ export interface PlanReviewOverlayOptions {
|
|
|
166
168
|
annotationState?: PlanReviewAnnotationState;
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
/** Default trailing footer hint when the caller supplies none. */
|
|
170
|
-
const DEFAULT_HELP_SUFFIX = "esc cancel";
|
|
171
|
-
|
|
172
171
|
export class PlanReviewOverlay implements Component {
|
|
173
172
|
#mdTheme: MarkdownTheme;
|
|
174
173
|
#scrollView: ScrollView;
|
|
@@ -185,7 +184,7 @@ export class PlanReviewOverlay implements Component {
|
|
|
185
184
|
|
|
186
185
|
#options: string[];
|
|
187
186
|
#disabled: Set<number>;
|
|
188
|
-
#helpSuffix: string;
|
|
187
|
+
#helpSuffix: string | undefined;
|
|
189
188
|
#externalEditorLabel: string | undefined;
|
|
190
189
|
#promptTitle: string | undefined;
|
|
191
190
|
#selectedIndex: number;
|
|
@@ -240,7 +239,7 @@ export class PlanReviewOverlay implements Component {
|
|
|
240
239
|
this.#disabled = new Set(
|
|
241
240
|
(options.disabledIndices ?? []).filter(i => Number.isInteger(i) && i >= 0 && i < this.#options.length),
|
|
242
241
|
);
|
|
243
|
-
this.#helpSuffix = options.helpText
|
|
242
|
+
this.#helpSuffix = options.helpText;
|
|
244
243
|
this.#externalEditorLabel = options.externalEditorLabel;
|
|
245
244
|
this.#promptTitle = options.promptTitle;
|
|
246
245
|
this.#selectedIndex = this.#coerceIndex(options.initialIndex ?? 0);
|
|
@@ -1069,22 +1068,39 @@ export class PlanReviewOverlay implements Component {
|
|
|
1069
1068
|
#buildHelp(): string {
|
|
1070
1069
|
const sep = " · ";
|
|
1071
1070
|
const parts: string[] = [];
|
|
1071
|
+
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
1072
|
+
const enter = formatKeyHint("enter");
|
|
1072
1073
|
switch (this.#focus) {
|
|
1073
1074
|
case "actions":
|
|
1074
|
-
parts.push(
|
|
1075
|
-
if (this.#slider) parts.push("
|
|
1075
|
+
parts.push(`${upDown} select`, `${enter} confirm`);
|
|
1076
|
+
if (this.#slider) parts.push(`${formatKeyHints(["left", "right"])} model`);
|
|
1076
1077
|
break;
|
|
1077
1078
|
case "toc":
|
|
1078
|
-
parts.push(
|
|
1079
|
+
parts.push(
|
|
1080
|
+
`${upDown} section`,
|
|
1081
|
+
`${enter} open`,
|
|
1082
|
+
`${formatKeyHint("a")} annotate`,
|
|
1083
|
+
`${formatKeyHint("e")} edit`,
|
|
1084
|
+
`${formatKeyHint("d")} delete`,
|
|
1085
|
+
`${formatKeyHint("u")} undo`,
|
|
1086
|
+
);
|
|
1079
1087
|
break;
|
|
1080
1088
|
case "body":
|
|
1081
|
-
parts.push(
|
|
1089
|
+
parts.push(
|
|
1090
|
+
`${upDown} scroll`,
|
|
1091
|
+
`${formatKeyHint("shift")} faster`,
|
|
1092
|
+
formatKeyHints(["pageUp", "pageDown"]),
|
|
1093
|
+
`${formatKeyHints(["g", "shift+g"])} ends`,
|
|
1094
|
+
`${formatKeyHint("a")} annotate`,
|
|
1095
|
+
`${formatKeyHint("e")} edit`,
|
|
1096
|
+
`${formatKeyHint("u")} undo`,
|
|
1097
|
+
);
|
|
1082
1098
|
break;
|
|
1083
1099
|
}
|
|
1084
|
-
if (this.callbacks.onCopyPlan) parts.push("c copy
|
|
1085
|
-
parts.push("tab regions
|
|
1100
|
+
if (this.callbacks.onCopyPlan) parts.push(`${formatKeyHint("c")} copy`);
|
|
1101
|
+
parts.push(`${formatKeyHint("tab")} regions`);
|
|
1086
1102
|
if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
|
|
1087
|
-
parts.push(this.#helpSuffix);
|
|
1103
|
+
parts.push(this.#helpSuffix ?? `${editorKey("tui.select.cancel")} cancel`);
|
|
1088
1104
|
return parts.join(sep);
|
|
1089
1105
|
}
|
|
1090
1106
|
|
|
@@ -1295,7 +1311,13 @@ export class PlanReviewOverlay implements Component {
|
|
|
1295
1311
|
lines.push(truncateToWidth(label, innerWidth, Ellipsis.Unicode));
|
|
1296
1312
|
}
|
|
1297
1313
|
if (!Number.isFinite(maxRows) || maxRows >= 2) {
|
|
1298
|
-
|
|
1314
|
+
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
1315
|
+
lines.push(
|
|
1316
|
+
theme.fg(
|
|
1317
|
+
"dim",
|
|
1318
|
+
`${upDown} choose · ${formatKeyHint("enter")} edit · ${editorKey("tui.select.cancel")} cancel`,
|
|
1319
|
+
),
|
|
1320
|
+
);
|
|
1299
1321
|
}
|
|
1300
1322
|
return lines.slice(0, Math.max(0, Math.floor(maxRows)));
|
|
1301
1323
|
}
|
|
@@ -1315,7 +1337,11 @@ export class PlanReviewOverlay implements Component {
|
|
|
1315
1337
|
innerWidth,
|
|
1316
1338
|
Ellipsis.Unicode,
|
|
1317
1339
|
);
|
|
1318
|
-
const hintParts = [
|
|
1340
|
+
const hintParts = [
|
|
1341
|
+
`${editorKey("tui.input.submit")} save`,
|
|
1342
|
+
`${editorKey("tui.input.newLine")} newline`,
|
|
1343
|
+
`${editorKey("tui.select.cancel")} cancel`,
|
|
1344
|
+
];
|
|
1319
1345
|
if (this.#editingAnnotation) hintParts.push("empty deletes");
|
|
1320
1346
|
if (this.#externalEditorLabel) hintParts.push(`${this.#externalEditorLabel} editor`);
|
|
1321
1347
|
this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, (process.stdout.rows || 40) - 12)));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Component, CURSOR_MARKER, type Focusable, Input, truncateToWidth, visibleWidth } from "../index";
|
|
2
2
|
import { theme } from "../theme/theme";
|
|
3
3
|
import { OverlayPanel, PanelRows } from "../chrome/overlay-box";
|
|
4
|
+
import { editorKey } from "../chrome/keybinding-hints";
|
|
4
5
|
|
|
5
6
|
/** A confirmed destination chosen from {@link PlanSaveOverlay}. */
|
|
6
7
|
export interface PlanSaveOverlayResult {
|
|
@@ -58,7 +59,8 @@ export class PlanSaveOverlay implements Component, Focusable {
|
|
|
58
59
|
render(width: number): readonly string[] {
|
|
59
60
|
const innerWidth = Math.max(0, width - 4);
|
|
60
61
|
this.#input.focused = this.#focused;
|
|
61
|
-
|
|
62
|
+
const hint = `${editorKey("tui.input.submit")} save and quit · ${editorKey("tui.select.cancel")} cancel`;
|
|
63
|
+
this.#body.setLines([this.#renderInput(innerWidth), theme.fg("dim", hint)]);
|
|
62
64
|
return this.#panel.render(width);
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -23,6 +23,7 @@ import { OverlayPanel } from "../chrome/overlay-box";
|
|
|
23
23
|
import { FormField, SelectFormField, TextFormField } from "../components/form";
|
|
24
24
|
import { formTheme } from "../chrome/form-theme";
|
|
25
25
|
import { SettingsFormField } from "../components/settings-list";
|
|
26
|
+
import { editorKey } from "../chrome/keybinding-hints";
|
|
26
27
|
|
|
27
28
|
/** Setting metadata consumed by the plugin settings UI. */
|
|
28
29
|
export type PluginSettingSchema = {
|
|
@@ -270,7 +271,16 @@ export class PluginListComponent extends OverlayPanel {
|
|
|
270
271
|
|
|
271
272
|
this.addChild(this.#selectList);
|
|
272
273
|
this.addChild(new Spacer(1));
|
|
273
|
-
this.addChild(
|
|
274
|
+
this.addChild(
|
|
275
|
+
new Text(
|
|
276
|
+
theme.fg(
|
|
277
|
+
"dim",
|
|
278
|
+
`${editorKey("tui.select.confirm")} to configure · ${editorKey("tui.select.cancel")} to go back`,
|
|
279
|
+
),
|
|
280
|
+
0,
|
|
281
|
+
0,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
#renderItem(entry: PluginListEntry): SelectItem {
|
|
@@ -408,7 +418,7 @@ export class PluginDetailComponent extends OverlayPanel {
|
|
|
408
418
|
items,
|
|
409
419
|
maxVisible: Math.min(items.length, 10),
|
|
410
420
|
settingsTheme: getSettingsListTheme(),
|
|
411
|
-
hint:
|
|
421
|
+
hint: `${editorKey("tui.select.confirm")} to edit · ${editorKey("tui.select.cancel")} to go back`,
|
|
412
422
|
onChange: (id, newValue) => {
|
|
413
423
|
if (id === "__enabled__") {
|
|
414
424
|
this.callbacks.onEnabledChange(newValue === "true");
|
|
@@ -540,7 +550,7 @@ export class MarketplacePluginDetailComponent extends OverlayPanel {
|
|
|
540
550
|
maxVisible: Math.min(items.length, 10),
|
|
541
551
|
settingsTheme: getSettingsListTheme(),
|
|
542
552
|
summary,
|
|
543
|
-
hint:
|
|
553
|
+
hint: `${editorKey("tui.select.confirm")} to edit · ${editorKey("tui.select.cancel")} to go back`,
|
|
544
554
|
onChange: (id, newValue) => {
|
|
545
555
|
if (id === "__enabled__") {
|
|
546
556
|
const next = newValue === "true";
|
|
@@ -571,9 +581,6 @@ export class MarketplacePluginDetailComponent extends OverlayPanel {
|
|
|
571
581
|
// Config Submenus
|
|
572
582
|
// =============================================================================
|
|
573
583
|
|
|
574
|
-
/** Shared footer hint for plugin detail lists. */
|
|
575
|
-
const DETAIL_FOOTER_HINT = "Enter to edit · Esc to go back";
|
|
576
|
-
|
|
577
584
|
/**
|
|
578
585
|
* Thin OverlayPanel boundary around a shared form field. The panel title stays
|
|
579
586
|
* the setting key; input, paste, focus, and mouse all forward to the field.
|
|
@@ -631,7 +638,7 @@ function createConfigEnumPanel(
|
|
|
631
638
|
currentValue,
|
|
632
639
|
maxVisible: 8,
|
|
633
640
|
selectTheme: getSelectListTheme(),
|
|
634
|
-
hint: "
|
|
641
|
+
hint: `${editorKey("tui.select.confirm")} to select · ${editorKey("tui.select.cancel")} to cancel`,
|
|
635
642
|
onSubmit: onSelect,
|
|
636
643
|
onCancel,
|
|
637
644
|
requestRender,
|
|
@@ -660,7 +667,7 @@ function createConfigInputPanel(
|
|
|
660
667
|
secret: schema.secret,
|
|
661
668
|
initialValue: !schema.secret ? currentValue : undefined,
|
|
662
669
|
empty: "cancel",
|
|
663
|
-
hint: "
|
|
670
|
+
hint: `${editorKey("tui.input.submit")} to save · ${editorKey("tui.select.cancel")} to cancel`,
|
|
664
671
|
onSubmit,
|
|
665
672
|
onCancel,
|
|
666
673
|
requestRender,
|
|
@@ -7,6 +7,8 @@ import { OverlayPanel } from "../chrome/overlay-box";
|
|
|
7
7
|
import { MenuSelection } from "../components/menu-selection";
|
|
8
8
|
import { centeredViewportRange } from "../components/scroll-viewport";
|
|
9
9
|
import { formatUsageResetWindow } from "./usage-display";
|
|
10
|
+
import { formatKeyHint } from "../app-keybindings";
|
|
11
|
+
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
10
12
|
|
|
11
13
|
const RESET_SELECTOR_MAX_VISIBLE = 10;
|
|
12
14
|
|
|
@@ -142,7 +144,10 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
|
|
|
142
144
|
const pending = items.find(item => this.#menu.isPending(item));
|
|
143
145
|
const hint = pending
|
|
144
146
|
? theme.fg("warning", oneLine(this.#confirmationMessage(pending)))
|
|
145
|
-
: theme.fg(
|
|
147
|
+
: theme.fg(
|
|
148
|
+
"muted",
|
|
149
|
+
`${editorKeys("tui.select.up", "tui.select.down")} select · ${formatKeyHint("enter")} spend a reset · ${editorKey("tui.select.cancel")} cancel`,
|
|
150
|
+
);
|
|
146
151
|
this.#listContainer.addChild(new Text(hint, 0, 0));
|
|
147
152
|
|
|
148
153
|
if (this.#statusMessage) {
|
|
@@ -153,7 +158,9 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
|
|
|
153
158
|
|
|
154
159
|
#confirmationMessage(account: ResetUsageAccount): string {
|
|
155
160
|
const subject = account.credit?.title ? `“${account.credit.title}”` : "1 saved reset";
|
|
156
|
-
const messages = [
|
|
161
|
+
const messages = [
|
|
162
|
+
`Press ${formatKeyHint("enter")} again to spend ${subject} for ${account.label} (${account.providerLabel}).`,
|
|
163
|
+
];
|
|
157
164
|
if (account.credit?.program === "juniper_tide") {
|
|
158
165
|
messages.push("This resets Claude's 5h session limit only; weekly limits stay unchanged.");
|
|
159
166
|
} else if (account.credit?.clears?.length) {
|
|
@@ -162,7 +169,7 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
|
|
|
162
169
|
if (account.credit?.requiresLimit === false) {
|
|
163
170
|
messages.push("Optional early use: this can be spent before the covered limit is reached.");
|
|
164
171
|
}
|
|
165
|
-
messages.push("
|
|
172
|
+
messages.push(`${editorKey("tui.select.cancel")} cancels.`);
|
|
166
173
|
return messages.join(" ");
|
|
167
174
|
}
|
|
168
175
|
|