Skip to main content

atmos/apps/terminal/
mod.rs

1//! CUI ターミナルアプリ(Aura REPL ホスト)。
2//!
3//! チャット型のコマンド入力・実行結果表示を提供する。実際の言語処理は
4//! [`crate::os_lib::aura`] に委譲し、本モジュールはバックグラウンドスレッドとの
5//! コマンド/結果のやり取り([`AuraThreadContext`]、[`SafeQueue`])と画面描画・
6//! 履歴管理(`shell` サブモジュール)を担う。
7pub mod shell;
8
9use crate::kernel::draw::{Color, Screen};
10use alloc::collections::VecDeque;
11use alloc::string::String;
12use alloc::sync::Arc;
13use alloc::vec::Vec;
14use core::sync::atomic::{AtomicBool, Ordering};
15use spin::Mutex;
16
17pub struct SafeQueue<T> {
18    queue: Mutex<VecDeque<T>>,
19}
20
21impl<T> SafeQueue<T> {
22    pub fn new() -> Self {
23        Self {
24            queue: Mutex::new(VecDeque::new()),
25        }
26    }
27    pub fn push(&self, value: T) {
28        self.queue.lock().push_back(value);
29    }
30    pub fn pop(&self) -> Option<T> {
31        self.queue.lock().pop_front()
32    }
33    /// 非破壊の空判定(メインループが未処理結果の有無を見て再描画を促すのに使う)。
34    pub fn is_empty(&self) -> bool {
35        self.queue.lock().is_empty()
36    }
37}
38
39impl<T> Default for SafeQueue<T> {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45pub struct AuraThreadContext {
46    pub cmd_queue: SafeQueue<String>,
47    pub result_queue: SafeQueue<String>,
48    pub exit_flag: AtomicBool,
49}
50
51impl AuraThreadContext {
52    pub fn new() -> Self {
53        Self {
54            cmd_queue: SafeQueue::new(),
55            result_queue: SafeQueue::new(),
56            exit_flag: AtomicBool::new(false),
57        }
58    }
59}
60
61impl Default for AuraThreadContext {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67pub struct Terminal {
68    logs: Vec<String>,
69    input_buffer: String,
70    bg_color: Color,
71    fg_color: Color,
72    pub(super) history: Vec<String>,
73    pub(super) history_index: usize,
74    pub dirty: bool,
75    pub input_dirty: bool,
76    pub cursor_char_idx: usize,
77    pub env: Option<crate::os_lib::aura::eval::Env>,
78    pub scroll_offset: usize,
79    pub context: Option<Arc<AuraThreadContext>>,
80    /// アプリランチャーのクリック領域 (x, y, w, h, app_name) — draw() 内で更新される
81    pub launcher_regions: Vec<(i32, i32, u32, u32, String)>,
82}
83
84impl Default for Terminal {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90fn aura_thread_entry(ctx_ptr: usize) {
91    let ctx = unsafe { Arc::from_raw(ctx_ptr as *const AuraThreadContext) };
92    let mut env = crate::os_lib::aura::eval::setup_global_env(core::ptr::null_mut());
93
94    while !ctx.exit_flag.load(Ordering::Relaxed) {
95        if let Some(cmd) = ctx.cmd_queue.pop() {
96            let res = crate::os_lib::aura::eval_string(&cmd, &mut env);
97            ctx.result_queue.push(res);
98        }
99        crate::kernel::scheduler::yield_now();
100    }
101}
102
103impl Drop for Terminal {
104    fn drop(&mut self) {
105        if let Some(ctx) = &self.context {
106            ctx.exit_flag.store(true, Ordering::Relaxed);
107        }
108    }
109}
110
111impl Terminal {
112    pub fn new() -> Self {
113        let context = Arc::new(AuraThreadContext::new());
114        let ctx_clone = context.clone();
115        let raw_ptr = Arc::into_raw(ctx_clone) as usize;
116
117        let pid = crate::kernel::scheduler::spawn_with_arg(
118            aura_thread_entry,
119            raw_ptr,
120            crate::kernel::scheduler::Priority::Normal,
121            "aura_shell",
122        );
123        crate::info!("[Terminal] Aura shell thread spawned pid={}", pid);
124
125        Self {
126            logs: Vec::new(),
127            input_buffer: String::new(),
128            launcher_regions: Vec::new(),
129            bg_color: Color(crate::kernel::config::get_config().theme.terminal_bg),
130            fg_color: Color(crate::kernel::config::get_config().theme.terminal_fg),
131            history: Vec::new(),
132            history_index: 0,
133            dirty: true,
134            input_dirty: true,
135            cursor_char_idx: 0,
136            env: None,
137            scroll_offset: 0,
138            context: Some(context),
139        }
140    }
141
142    pub fn scroll(&mut self, lines: i32) {
143        if lines > 0 {
144            // スクロールダウン(下へ、新しいログへ)
145            self.scroll_offset = self.scroll_offset.saturating_sub(lines as usize);
146        } else if lines < 0 {
147            // スクロールアップ(上へ、古いログへ)
148            let max_offset = self.logs.len();
149            self.scroll_offset = self
150                .scroll_offset
151                .saturating_add((-lines) as usize)
152                .min(max_offset);
153        }
154        self.dirty = true;
155    }
156
157    pub fn input_char(&mut self, c: char) {
158        self.scroll_offset = 0; // 入力時は最新行までスクロールを戻す
159        self.dirty = true;
160
161        if c == '\x03' {
162            // Ctrl+C: 入力バッファをクリアして改行風に表示
163            self.logs.push(alloc::format!("> {}^C", self.input_buffer));
164            self.input_buffer.clear();
165            self.cursor_char_idx = 0;
166            self.input_dirty = true;
167            return;
168        }
169
170        let chars: Vec<char> = self.input_buffer.chars().collect();
171        let count = chars.len();
172
173        if c == '\x08' {
174            if self.cursor_char_idx > 0 && self.cursor_char_idx <= count {
175                let mut new_chars = chars;
176                new_chars.remove(self.cursor_char_idx - 1);
177                self.input_buffer = new_chars.into_iter().collect();
178                self.cursor_char_idx -= 1;
179            }
180        } else if c.is_ascii_control() {
181            // 他の制御文字(Ctrl+A / Ctrl+E / Ctrl+K などの処理)
182            match c {
183                '\x01' => self.move_cursor_to_start(),
184                '\x05' => self.move_cursor_to_end(),
185                '\x10' => self.history_up(),
186                '\x0E' => self.history_down(),
187                '\x0B' => {
188                    // Ctrl+K: カーソルから行末までの削除
189                    let chars: Vec<char> = self.input_buffer.chars().collect();
190                    if self.cursor_char_idx <= chars.len() {
191                        self.input_buffer = chars[0..self.cursor_char_idx].iter().collect();
192                        self.input_dirty = true;
193                    }
194                }
195                _ => {}
196            }
197        } else {
198            let mut new_chars = chars;
199            if self.cursor_char_idx <= count {
200                new_chars.insert(self.cursor_char_idx, c);
201                self.input_buffer = new_chars.into_iter().collect();
202                self.cursor_char_idx += 1;
203            }
204        }
205        self.input_dirty = true;
206    }
207
208    pub fn delete_char(&mut self) {
209        let chars: Vec<char> = self.input_buffer.chars().collect();
210        let count = chars.len();
211        if self.cursor_char_idx < count {
212            let mut new_chars = chars;
213            new_chars.remove(self.cursor_char_idx);
214            self.input_buffer = new_chars.into_iter().collect();
215            self.input_dirty = true;
216        }
217    }
218
219    pub fn move_cursor_left(&mut self) {
220        if self.cursor_char_idx > 0 {
221            self.cursor_char_idx -= 1;
222            self.input_dirty = true;
223        }
224    }
225
226    pub fn move_cursor_right(&mut self) {
227        let count = self.input_buffer.chars().count();
228        if self.cursor_char_idx < count {
229            self.cursor_char_idx += 1;
230            self.input_dirty = true;
231        }
232    }
233
234    pub fn move_cursor_to_start(&mut self) {
235        self.cursor_char_idx = 0;
236        self.input_dirty = true;
237    }
238
239    /// Ctrl+K (KillLine): カーソル位置から現在行の行末までを削除する(OS 共通)。
240    pub fn kill_line(&mut self) {
241        let chars: Vec<char> = self.input_buffer.chars().collect();
242        let mut end = self.cursor_char_idx;
243        while end < chars.len() && chars[end] != '\n' {
244            end += 1;
245        }
246        if end > self.cursor_char_idx {
247            let mut new_chars = chars;
248            new_chars.drain(self.cursor_char_idx..end);
249            self.input_buffer = new_chars.into_iter().collect();
250            self.input_dirty = true;
251        }
252    }
253
254    pub fn move_cursor_to_end(&mut self) {
255        self.cursor_char_idx = self.input_buffer.chars().count();
256        self.input_dirty = true;
257    }
258
259    pub fn submit(&mut self) -> Option<String> {
260        if !self.input_buffer.is_empty() {
261            // 入力された文字列をログに追加。実行時刻を '\x00' 区切りで末尾に埋め込み、
262            // 描画時に右端(時計と同じ x)へ分離表示する。
263            let mut log_line = String::from("> ");
264            log_line.push_str(&self.input_buffer);
265            if let Some(t) = crate::kernel::timer::format_time_hms_jst() {
266                log_line.push('\x00');
267                log_line.push_str(&t);
268            }
269            self.logs.push(log_line);
270
271            let command = self.input_buffer.clone();
272
273            if self.history.last() != Some(&command) {
274                self.history.push(command.clone());
275            }
276            self.history_index = self.history.len();
277
278            self.input_buffer.clear();
279            self.cursor_char_idx = 0;
280            self.dirty = true;
281            Some(command)
282        } else {
283            None
284        }
285    }
286
287    pub fn history_up(&mut self) {
288        self.scroll_offset = 0;
289        if self.history_index > 0 {
290            self.history_index -= 1;
291            self.input_buffer = self.history[self.history_index].clone();
292            self.cursor_char_idx = self.input_buffer.chars().count();
293            self.dirty = true;
294            self.input_dirty = true;
295        }
296    }
297
298    pub fn history_down(&mut self) {
299        self.scroll_offset = 0;
300        if self.history_index < self.history.len() {
301            self.history_index += 1;
302            if self.history_index == self.history.len() {
303                self.input_buffer.clear();
304            } else {
305                self.input_buffer = self.history[self.history_index].clone();
306            }
307            self.cursor_char_idx = self.input_buffer.chars().count();
308            self.dirty = true;
309            self.input_dirty = true;
310        }
311    }
312
313    pub fn print_line(&mut self, msg: String) {
314        self.logs.push(msg);
315        self.dirty = true;
316    }
317
318    pub fn clear_logs(&mut self) {
319        self.logs.clear();
320        self.dirty = true;
321    }
322
323    /// 非同期 Aura スレッドの実行結果(result_queue)が未処理で残っているか。
324    /// メインループはこれを見て `dirty` を立て、draw() に結果を吸い出させる。
325    /// (draw() 内でしか result_queue を消費しないため、結果到着時に再描画を促す必要がある)
326    pub fn has_pending_results(&self) -> bool {
327        self.context
328            .as_ref()
329            .map(|c| !c.result_queue.is_empty())
330            .unwrap_or(false)
331    }
332
333    pub fn show_history(&mut self) {
334        let history = self.history.clone();
335        for (i, cmd) in history.iter().enumerate() {
336            self.print_line(alloc::format!("  {}  {}", i + 1, cmd));
337        }
338    }
339
340    /// クリック座標がランチャー領域に当たればアプリ名を返す
341    pub fn handle_click(&self, x: i32, y: i32) -> Option<String> {
342        for (rx, ry, rw, rh, name) in &self.launcher_regions {
343            if x >= *rx && x < rx + *rw as i32 && y >= *ry && y < ry + *rh as i32 {
344                return Some(name.clone());
345            }
346        }
347        None
348    }
349
350    pub fn draw(&mut self, screen: &Screen, wx: i32, wy: i32, ww: u32, wh: u32) {
351        if let Some(ctx) = self.context.clone() {
352            while let Some(res) = ctx.result_queue.pop() {
353                if !res.is_empty() {
354                    for line in res.split('\n') {
355                        self.print_line(String::from(line));
356                    }
357                }
358            }
359        }
360
361        let config = crate::kernel::config::get_config();
362        self.bg_color = Color(config.theme.terminal_bg);
363        self.fg_color = Color(config.theme.terminal_fg);
364
365        let ux0 = wx.max(0) as u32;
366        let uy0 = wy.max(0) as u32;
367        let ux1 = (wx + ww as i32).max(0) as u32;
368        let uy1 = (wy + wh as i32).max(0) as u32;
369
370        // 1. ウィンドウ領域を背景色でクリア
371        screen.boxfill(ux0, uy0, ux1, uy1, self.bg_color);
372
373        let line_height = 20; // 1行の高さ(余白込み)
374        let padding_x = 10;
375        let max_width = ((ww - padding_x * 2 - 12) / 8) as usize; // 半角文字数としての最大幅(スクロールバー余白12px考慮)
376
377        // 2. 入力バッファを論理表示行リストに分割 (改行・自動折り返し対応)
378        let mut display_lines: Vec<String> = Vec::new();
379        let mut current_line = String::from("> ");
380        let mut current_width = 2; // "> " の幅は2
381
382        let mut target_line_idx = 0;
383        let mut cursor_found = false;
384
385        let chars_list: Vec<char> = self.input_buffer.chars().collect();
386
387        let mut target_string = String::new();
388
389        if self.cursor_char_idx == 0 {
390            target_line_idx = 0;
391            target_string = String::from("> ");
392            cursor_found = true;
393        }
394
395        for (idx, &c) in chars_list.iter().enumerate() {
396            let char_w = if (c as u32) < 128 { 1 } else { 2 };
397            if c == '\n' {
398                display_lines.push(current_line);
399                current_line = String::from("  "); // 改行後のインデント
400                current_width = 2;
401            } else {
402                if current_width + char_w > max_width {
403                    display_lines.push(current_line);
404                    current_line = String::from("  "); // 自動折り返し後のインデント
405                    current_width = 2;
406                }
407                current_line.push(c);
408                current_width += char_w;
409            }
410
411            if idx + 1 == self.cursor_char_idx {
412                target_line_idx = display_lines.len();
413                target_string = current_line.clone();
414                cursor_found = true;
415            }
416        }
417        display_lines.push(current_line.clone());
418
419        if !cursor_found {
420            target_line_idx = display_lines.len() - 1;
421            target_string = current_line.clone();
422        }
423
424        // ランチャー領域をリセット(draw のたびに再計算)
425        self.launcher_regions.clear();
426
427        // 2.5 ログを論理表示行リストに分割 (改行・自動折り返し対応、色付け)
428        // 要素: (text, color, timestamp_opt, launcher_app_name_opt)
429        // launcher_app_name_opt が Some のとき SVG アイコン付きのランチャー行として描画する
430        let mut display_logs: alloc::vec::Vec<(String, Color, Option<String>, Option<String>)> =
431            alloc::vec::Vec::new();
432        for raw_log in &self.logs {
433            // ランチャーマーカー: "\x02LAUNCHER:appname:display\x02"
434            if raw_log.starts_with('\x02') {
435                let inner = raw_log.trim_matches('\x02');
436                if let Some(rest) = inner.strip_prefix("LAUNCHER:") {
437                    if let Some((app_name, display)) = rest.split_once(':') {
438                        display_logs.push((
439                            String::from(display),
440                            Color(config.theme.fn_bar_key_fg), // ランチャー色
441                            None,
442                            Some(String::from(app_name)),
443                        ));
444                    }
445                }
446                continue;
447            }
448
449            let is_input = raw_log.starts_with("> ");
450            // 実行時刻('\x00' 区切りで埋め込み)を分離
451            let (log, mut timestamp): (&str, Option<String>) = match raw_log.split_once('\x00') {
452                Some((cmd, ts)) => (cmd, Some(String::from(ts))),
453                None => (raw_log.as_str(), None),
454            };
455            let is_error = log.to_lowercase().contains("error");
456
457            let color = if is_input {
458                Color(config.theme.success_fg) // 入力
459            } else if is_error {
460                Color(config.theme.error_fg) // エラー
461            } else {
462                self.fg_color // 出力は通常色
463            };
464
465            let mut current_line = String::new();
466            let mut current_width = 0;
467            for c in log.chars() {
468                let char_w = if (c as u32) < 128 { 1 } else { 2 };
469                if c == '\n' {
470                    display_logs.push((current_line.clone(), color, timestamp.take(), None));
471                    current_line.clear();
472                    current_width = 0;
473                } else {
474                    if current_width + char_w > max_width {
475                        display_logs.push((current_line.clone(), color, timestamp.take(), None));
476                        current_line.clear();
477                        current_width = 0;
478                    }
479                    current_line.push(c);
480                    current_width += char_w;
481                }
482            }
483            display_logs.push((current_line, color, timestamp.take(), None));
484        }
485
486        let total_lines = display_lines.len();
487        let visible_lines_count = total_lines.min(5);
488
489        let input_area_height = visible_lines_count as u32 * line_height + 15;
490        let log_area_height = wh.saturating_sub(input_area_height);
491        let max_log_lines = log_area_height / line_height;
492
493        // 3. ログエリアの描画 (タイプライター風: 下から上へ押し上げる)
494        let max_possible_start = if display_logs.len() as u32 > max_log_lines {
495            display_logs.len() - max_log_lines as usize
496        } else {
497            0
498        };
499        let start_idx = max_possible_start.saturating_sub(self.scroll_offset);
500        let end_idx = (start_idx + max_log_lines as usize).min(display_logs.len());
501
502        // 描画すべき行数
503        let lines_to_draw = end_idx - start_idx;
504
505        // 入力エリアの境界線 y座標
506        let sep_y = wy + wh as i32 - input_area_height as i32;
507
508        // 最新の行が sep_y の直上にくるように current_y を逆算
509        let mut current_y = sep_y - (lines_to_draw as i32 * line_height as i32) - 5;
510
511        static DEFAULT_APP_ICON: &[u8] =
512            include_bytes!("../../../icons/ic_app_default.svg");
513
514        for i in start_idx..end_idx {
515            let (text, color, ts, launcher_app) = &display_logs[i];
516
517            if let Some(app_name) = launcher_app {
518                // ランチャー行: アイコン + テキスト + クリック領域
519                let icon_size = 16u32;
520                let icon_x = (wx + padding_x as i32) as u32;
521                let icon_y = if current_y >= 0 { current_y as u32 } else { 0 };
522                let text_x = icon_x + icon_size + 6;
523                let row_h = line_height;
524
525                // ホバー背景(薄いハイライト)
526                let hl_y = if current_y >= 0 { current_y as u32 } else { 0 };
527                screen.boxfill(
528                    (wx + 2) as u32,
529                    hl_y,
530                    (wx + ww as i32 - 20) as u32,
531                    hl_y + row_h,
532                    Color(config.theme.ui_overlay_bg),
533                );
534
535                // SVGアイコン: /apps/<name>/icon.svg をFS から読む、なければデフォルト
536                let icon_path = alloc::format!("/apps/{}/icon.svg", app_name);
537                let fs = crate::kernel::fs::get_fs();
538                let icon_bytes_owned: Option<alloc::vec::Vec<u8>> =
539                    fs.read_file(&icon_path).map(|(_m, b)| b);
540                let icon_data: &[u8] = match &icon_bytes_owned {
541                    Some(b) => b.as_slice(),
542                    None => DEFAULT_APP_ICON,
543                };
544                if icon_y + icon_size < (wy + wh as i32) as u32 {
545                    screen.draw_svg_icon(icon_x, icon_y, icon_data, icon_size, *color);
546                }
547
548                // アプリ名テキスト
549                screen.draw_string_monospace_bg(
550                    text_x,
551                    icon_y,
552                    text,
553                    *color,
554                    Color(config.theme.ui_overlay_bg),
555                );
556
557                // クリック領域を登録
558                self.launcher_regions.push((
559                    wx + 2,
560                    current_y,
561                    ww - 22,
562                    row_h,
563                    app_name.clone(),
564                ));
565            } else {
566                screen.draw_string_monospace_bg(
567                    (wx + padding_x as i32) as u32,
568                    current_y as u32,
569                    text,
570                    *color,
571                    self.bg_color,
572                );
573                // コマンド実行時刻を右端(時計と同じ右基準)に表示
574                if let Some(t) = ts {
575                    let tw = screen.measure_string_monospace(t);
576                    let tx = (wx + ww as i32 - padding_x as i32 - tw as i32).max(wx + padding_x as i32);
577                    screen.draw_string_monospace_bg(
578                        tx as u32,
579                        current_y as u32,
580                        t,
581                        Color(config.theme.fn_bar_dim_fg),
582                        self.bg_color,
583                    );
584                }
585            }
586            current_y += line_height as i32;
587        }
588
589        // 3.5 ログエリア右下に時計またはNTPステータスを表示
590        let clock_str = crate::kernel::timer::format_datetime_jst()
591            .unwrap_or_else(|| alloc::string::String::from("NTP Syncing..."));
592        
593        let clock_w = screen.measure_string_monospace(&clock_str);
594        let clock_x =
595            (wx + ww as i32 - padding_x as i32 - clock_w as i32).max(wx + padding_x as i32);
596        let clock_y = sep_y - line_height as i32 + 2;
597        screen.draw_string_monospace_bg(
598            clock_x as u32,
599            clock_y as u32,
600            &clock_str,
601            Color(config.theme.fn_bar_dim_fg),
602            self.bg_color,
603        );
604
605        // 3.6 縦スクロールバーの描画
606        let total_log_lines = display_logs.len();
607        if total_log_lines > max_log_lines as usize {
608            let bar_w = 6u32;
609            let bar_padding = 2u32;
610            let rail_x = wx + ww as i32 - bar_w as i32 - bar_padding as i32;
611            let rail_y = wy + 5;
612            let rail_h = log_area_height.saturating_sub(10);
613
614            // スクロールバーの背景レール
615            screen.boxfill(
616                rail_x as u32,
617                rail_y as u32,
618                (rail_x + bar_w as i32) as u32,
619                (rail_y + rail_h as i32) as u32,
620                Color(config.theme.tab_area_bg),
621            );
622
623            // つまみの高さと位置の計算
624            let thumb_h = ((rail_h * max_log_lines) / total_log_lines as u32).max(15);
625            let max_possible_start = total_log_lines.saturating_sub(max_log_lines as usize);
626            if max_possible_start > 0 {
627                let scroll_y_offset =
628                    ((rail_h - thumb_h) * start_idx as u32) / max_possible_start as u32;
629                let thumb_y = rail_y + scroll_y_offset as i32;
630                // スクロールバーのつまみ
631                screen.boxfill(
632                    rail_x as u32,
633                    thumb_y as u32,
634                    (rail_x + bar_w as i32) as u32,
635                    (thumb_y + thumb_h as i32) as u32,
636                    Color(config.theme.tab_area_border),
637                );
638            }
639        }
640
641        // 4. 入力エリアの境界線
642        let sep_y = wy + wh as i32 - input_area_height as i32;
643        screen.boxfill(
644            ux0,
645            sep_y as u32,
646            ux1,
647            (sep_y + 1) as u32,
648            Color(config.theme.tab_area_border),
649        );
650
651        // 5. 入力バッファの描画 (スライド窓スクロール)
652        let start_line_idx = total_lines.saturating_sub(5);
653        let mut cur_y = sep_y + 10;
654
655        for i in start_line_idx..total_lines {
656            let line = &display_lines[i];
657
658            // 最初の行はプロンプト "> " (2文字分) をエメラルドグリーンにする
659            if i == 0 {
660                screen.draw_string_monospace_bg(
661                    (wx + padding_x as i32) as u32,
662                    cur_y as u32,
663                    "> ",
664                    Color(config.theme.success_fg),
665                    self.bg_color,
666                );
667                if line.len() > 2 {
668                    let text = line.get(2..).unwrap_or("");
669                    screen.draw_string_monospace_bg(
670                        (wx + padding_x as i32 + 16) as u32,
671                        cur_y as u32,
672                        text,
673                        self.fg_color,
674                        self.bg_color,
675                    );
676                }
677            } else {
678                screen.draw_string_monospace_bg(
679                    (wx + padding_x as i32) as u32,
680                    cur_y as u32,
681                    line,
682                    self.fg_color,
683                    self.bg_color,
684                );
685            }
686
687            cur_y += line_height as i32;
688        }
689
690        // 6. カーソルの描画
691        if target_line_idx >= start_line_idx {
692            let relative_line_idx = target_line_idx - start_line_idx;
693
694            // target_string の幅を等幅フォントで計算
695            let mut prefix_w = 0;
696            if target_line_idx == 0 {
697                if target_string.len() >= 2 {
698                    prefix_w += 16; // "> " のプロンプト幅 (1文字8px*2=16px)
699                    let text = target_string.get(2..).unwrap_or("");
700                    prefix_w += screen.measure_string_monospace(text);
701                } else {
702                    prefix_w += screen.measure_string_monospace(&target_string);
703                }
704            } else {
705                prefix_w += screen.measure_string_monospace(&target_string);
706            }
707
708            let cursor_x = wx + padding_x as i32 + prefix_w as i32;
709            let cursor_y = sep_y + 10 + (relative_line_idx as i32) * line_height as i32;
710
711            if cursor_x + 2 <= wx + ww as i32 - padding_x as i32 {
712                // 縦棒カーソル (幅2px)。半角=青系 / 全角(FEP)=赤系。
713                screen.boxfill(
714                    cursor_x as u32,
715                    cursor_y as u32 + 2,
716                    (cursor_x + 2) as u32,
717                    (cursor_y + 14) as u32,
718                    Color(crate::kernel::ime::cursor_color()),
719                );
720            }
721        }
722
723        self.dirty = false;
724        self.input_dirty = false;
725    }
726}